diff --git a/backend/app/src/main/java/com/ugent/pidgeon/config/WebConfig.java b/backend/app/src/main/java/com/ugent/pidgeon/config/WebConfig.java index 0009ac5d..ea2dc330 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/config/WebConfig.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/config/WebConfig.java @@ -23,7 +23,9 @@ public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedMethods("*") .allowedOrigins("*") + .exposedHeaders("Content-Disposition") .allowedHeaders("*"); + } diff --git a/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java b/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java index 1028825c..d279c51c 100644 --- a/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java +++ b/backend/app/src/main/java/com/ugent/pidgeon/controllers/SubmissionController.java @@ -23,6 +23,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -203,6 +204,7 @@ public ResponseEntity getSubmissions(@PathVariable("projectid") long projecti */ @PostMapping(ApiRoutes.PROJECT_BASE_PATH + "/{projectid}/submit") //Route to submit a file, it accepts a multiform with the file and submissionTime + @Transactional @Roles({UserRole.teacher, UserRole.student}) public ResponseEntity submitFile(@RequestParam("file") MultipartFile file, @PathVariable("projectid") long projectid, Auth auth) { long userId = auth.getUserEntity().getId(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d4c867e4..7e2ec1d4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,7 @@ "framer-motion": "^11.0.24", "highlight.js": "^11.9.0", "i18next-localstorage-cache": "^1.1.1", + "jszip": "^3.10.1", "lowlight": "^3.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -3545,6 +3546,11 @@ "toggle-selection": "^1.0.6" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -4852,6 +4858,11 @@ "node": ">=4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/import-local": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", @@ -4901,8 +4912,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/inline-style-parser": { "version": "0.2.3", @@ -7464,6 +7474,17 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -7482,6 +7503,14 @@ "node": ">=6" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -8435,6 +8464,11 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/parse-entities": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.1.tgz", @@ -8627,6 +8661,11 @@ "node": ">=6" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -9430,6 +9469,25 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -9699,6 +9757,11 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -9772,6 +9835,11 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -9895,6 +9963,14 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", @@ -10425,6 +10501,11 @@ "react": "^16.8.0 || ^17 || ^18" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/v8-to-istanbul": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a38f3933..d0d0e81f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "framer-motion": "^11.0.24", "highlight.js": "^11.9.0", "i18next-localstorage-cache": "^1.1.1", + "jszip": "^3.10.1", "lowlight": "^3.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/frontend/src/@types/requests.d.ts b/frontend/src/@types/requests.d.ts index 63f6846a..99a04918 100644 --- a/frontend/src/@types/requests.d.ts +++ b/frontend/src/@types/requests.d.ts @@ -15,17 +15,32 @@ export enum ApiRoutes { COURSE_GRADES = '/api/courses/:id/grades', COURSE_LEAVE = "api/courses/:courseId/leave", COURSE_COPY = "/api/courses/:courseId/copy", - - PROJECTS = "api/projects", - PROJECT = "api/projects/:id", - PROJECT_CREATE = "api/courses/:courseId/projects", - PROJECT_TESTS = "api/projects/:id/tests", - PROJECT_SUBMISSIONS = "api/projects/:id/submissions", - PROJECT_SCORE = "api/projects/:id/groups/:groupId/score", - PROJECT_GROUP = "api/projects/:id/groups/:groupId", - PROJECT_GROUPS = "api/projects/:id/groups", - PROJECT_GROUP_SUBMISSIONS = "api/projects/:projectId/submissions/:groupId", - + USER_COURSES = "api/courses", + COURSES = "api/courses", + + COURSE = "api/courses/:courseId", + COURSE_MEMBERS = "api/courses/:courseId/members", + COURSE_MEMBER = "api/courses/:courseId/members/:userId", + COURSE_PROJECTS = "api/courses/:id/projects", + COURSE_CLUSTERS = "api/courses/:id/clusters", + COURSE_GRADES = '/api/courses/:id/grades', + COURSE_LEAVE = "api/courses/:courseId/leave", + + PROJECTS = "api/projects", + PROJECT = "api/projects/:id", + PROJECT_CREATE = "api/courses/:courseId/projects", + PROJECT_TESTS = "api/projects/:id/tests", + PROJECT_SUBMISSIONS = "api/projects/:id/submissions", + PROJECT_SUBMIT = "api/projects/:id/submit", + PROJECT_SCORE = "api/projects/:id/groups/:groupId/score", + PROJECT_GROUP = "api/projects/:id/groups/:groupId", + PROJECT_GROUPS = "api/projects/:id/groups", + PROJECT_GROUP_SUBMISSIONS = "api/projects/:projectId/submissions/:groupId", + + SUBMISSION = "api/submissions/:id", + SUBMISSION_FILE = "api/submissions/:id/file", + SUBMISSION_STRUCTURE_FEEDBACK = "/api/submissions/:id/structurefeedback", + SUBMISSION_DOCKER_FEEDBACK = "/api/submissions/:id/dockerfeedback", SUBMISSION = "api/submissions/:id", SUBMISSION_FILE = "api/submissions/:id/file", SUBMISSION_STRUCTURE_FEEDBACK= "/api/submissions/:id/structurefeedback", @@ -33,13 +48,17 @@ export enum ApiRoutes { SUBMISSION_ARTIFACT="/api/submissions/:id/artifacts", - CLUSTER = "api/clusters/:id", + CLUSTER = "api/clusters/:id", - GROUP = "api/groups/:id", - GROUP_MEMBERS = "api/groups/:id/members", - GROUP_MEMBER = "api/groups/:id/members/:userId", - GROUP_SUBMISSIONS = "api/projects/:id/groups/:id/submissions", + GROUP = "api/groups/:id", + GROUP_MEMBERS = "api/groups/:id/members", + GROUP_MEMBER = "api/groups/:id/members/:userId", + GROUP_SUBMISSIONS = "api/projects/:id/groups/:id/submissions", + TEST = "api/test", + USER = "api/users/:id", + USERS = "api/users", + USER_AUTH = "api/user", USER = "api/users/:id", USERS = "api/users", USER_AUTH = "api/user", @@ -51,6 +70,12 @@ export type Timestamp = string * the body of the POST requests */ export type POST_Requests = { + [ApiRoutes.COURSES]: { + name: string + description: string + } + [ApiRoutes.PROJECT_CREATE]: + ProjectFormData [ApiRoutes.COURSES]: { name: string description:string @@ -63,12 +88,20 @@ export type POST_Requests = { visible: boolean; maxScore: number; deadline: Date | null; -} +} [ApiRoutes.GROUP_MEMBERS]: { - id: number + id: number + } + [ApiRoutes.PROJECT_SUBMIT]: { + file: FormData } + [ApiRoutes.COURSE_CLUSTERS]: { + name: string + capacity: number + groupCount: number + } [ApiRoutes.COURSE_CLUSTERS]: { name: string capacity: number @@ -84,6 +117,11 @@ export type POST_Requests = { */ export type POST_Responses = { + [ApiRoutes.COURSES]: GET_Responses[ApiRoutes.COURSE], + [ApiRoutes.PROJECT_CREATE]: GET_Responses[ApiRoutes.PROJECT] + [ApiRoutes.GROUP_MEMBERS]: GET_Responses[ApiRoutes.GROUP_MEMBERS] + [ApiRoutes.COURSE_CLUSTERS]: GET_Responses[ApiRoutes.CLUSTER] + [ApiRoutes.PROJECT_SUBMIT]: GET_Responses[ApiRoutes.SUBMISSION] [ApiRoutes.COURSES]: GET_Responses[ApiRoutes.COURSE], [ApiRoutes.PROJECT_CREATE]: GET_Responses[ApiRoutes.PROJECT] [ApiRoutes.GROUP_MEMBERS]: GET_Responses[ApiRoutes.GROUP_MEMBERS] @@ -97,6 +135,11 @@ export type POST_Responses = { * the body of the DELETE requests */ export type DELETE_Requests = { + [ApiRoutes.COURSE]: undefined + [ApiRoutes.PROJECT]: undefined + [ApiRoutes.GROUP_MEMBER]: undefined + [ApiRoutes.COURSE_LEAVE]: undefined + [ApiRoutes.COURSE_MEMBER]: undefined [ApiRoutes.COURSE]: undefined [ApiRoutes.PROJECT]: undefined [ApiRoutes.GROUP_MEMBER]: undefined @@ -110,6 +153,10 @@ export type DELETE_Requests = { * the body of the PUT & PATCH requests */ export type PUT_Requests = { + [ApiRoutes.COURSE]: POST_Requests[ApiRoutes.COURSE] + [ApiRoutes.PROJECT]: ProjectFormData + [ApiRoutes.COURSE_MEMBER]: { relation: CourseRelation } + [ApiRoutes.PROJECT_SCORE]: { score: number | null, feedback: string } [ApiRoutes.COURSE]: POST_Requests[ApiRoutes.COURSE] [ApiRoutes.PROJECT]: ProjectFormData [ApiRoutes.COURSE_MEMBER]: { relation: CourseRelation } @@ -118,8 +165,11 @@ export type PUT_Requests = { } - export type PUT_Responses = { + [ApiRoutes.COURSE]: GET_Responses[ApiRoutes.COURSE] + [ApiRoutes.PROJECT]: GET_Responses[ApiRoutes.PROJECT] + [ApiRoutes.COURSE_MEMBER]: GET_Responses[ApiRoutes.COURSE_MEMBERS] + [ApiRoutes.PROJECT_SCORE]: GET_Responses[ApiRoutes.PROJECT_SCORE] [ApiRoutes.COURSE]: GET_Responses[ApiRoutes.COURSE] [ApiRoutes.PROJECT]: GET_Responses[ApiRoutes.PROJECT] [ApiRoutes.COURSE_MEMBER]: GET_Responses[ApiRoutes.COURSE_MEMBERS] @@ -129,14 +179,14 @@ export type PUT_Responses = { type CourseTeacher = { - name: string - surname: string - url: string, + name: string + surname: string + url: string, } type Course = { - courseUrl: string - name: string + courseUrl: string + name: string } export type DockerStatus = "no_test" | "running" | "finished" | "aborted" @@ -154,7 +204,7 @@ type SubTest = { } type DockerFeedback = { - type: "SIMPLE", + type: "SIMPLE", feedback: string, // de logs van de dockerrun allowed: boolean // vat samen of de test geslaagd is of niet } | { @@ -175,9 +225,88 @@ type DockerFeedback = { * The response you get from the GET request */ export type GET_Responses = { + + [ApiRoutes.TEST]: { + name: string + firstName: string + lastName: string + email: string + oid: string + } + [ApiRoutes.PROJECT_SUBMISSIONS]: { + feedback: GET_Responses[ApiRoutes.PROJECT_SCORE] | null, + group: GET_Responses[ApiRoutes.GROUP], + submission: GET_Responses[ApiRoutes.SUBMISSION] | null // null if no submission yet + }[], + [ApiRoutes.PROJECT_GROUP_SUBMISSIONS]: GET_Responses[ApiRoutes.SUBMISSION][] + [ApiRoutes.GROUP_SUBMISSIONS]: GET_Responses[ApiRoutes.SUBMISSION] + [ApiRoutes.SUBMISSION]: { + submissionId: number + projectId: number + groupId: number + structureAccepted: boolean + dockerAccepted: boolean + submissionTime: Timestamp + projectUrl: ApiRoutes.PROJECT + groupUrl: ApiRoutes.GROUP + fileUrl: ApiRoutes.SUBMISSION_FILE + structureFeedbackUrl: ApiRoutes.SUBMISSION_STRUCTURE_FEEDBACK + dockerFeedbackUrl: ApiRoutes.SUBMISSION_DOCKER_FEEDBACK + } + [ApiRoutes.SUBMISSION_FILE]: BlobPart + [ApiRoutes.COURSE_PROJECTS]: GET_Responses[ApiRoutes.PROJECT][] + [ApiRoutes.PROJECT]: { + course: { + name: string + url: string + courseId: number + } + deadline: Timestamp + description: string + clusterId: number | null; + projectId: number + name: string + submissionUrl: ApiRoutes.PROJECT_GROUP_SUBMISSIONS + testsUrl: string + maxScore: number + visible: boolean + status?: ProjectStatus + progress: { + completed: number + total: number + }, + groupId: number | null // null if not in a group + } + [ApiRoutes.PROJECT_TESTS]: {} // ?? + [ApiRoutes.GROUP]: { + groupId: number, + capacity: number, + name: string + groupClusterUrl: ApiRoutes.CLUSTER + members: GET_Responses[ApiRoutes.GROUP_MEMBER][] + } + [ApiRoutes.PROJECT_SCORE]: { + score: number | null, + feedback: string | null, + projectId: number, + groupId: number + }, + [ApiRoutes.GROUP_MEMBER]: { + email: string + name: string + userId: number + } + [ApiRoutes.USERS]: { + name: string + userId: number + url: string + email: string + role: UserRole + } + [ApiRoutes.GROUP_MEMBERS]: GET_Responses[ApiRoutes.GROUP_MEMBER][] [ApiRoutes.PROJECT_SUBMISSIONS]: { - feedback: GET_Responses[ApiRoutes.PROJECT_SCORE] | null, - group: GET_Responses[ApiRoutes.GROUP], + feedback: GET_Responses[ApiRoutes.PROJECT_SCORE] | null, + group: GET_Responses[ApiRoutes.GROUP], submission: GET_Responses[ApiRoutes.SUBMISSION] | null // null if no submission yet }[], [ApiRoutes.PROJECT_GROUP_SUBMISSIONS]: GET_Responses[ApiRoutes.SUBMISSION][] @@ -223,11 +352,11 @@ export type GET_Responses = { } [ApiRoutes.PROJECT_TESTS]: { projectUrl: ApiRoutes.PROJECT, - dockerImage: string | null, + dockerImage: string | null, dockerScript: string | null, dockerTemplate: string | null, structureTest: string | null - } + } [ApiRoutes.GROUP]: { groupId: number, capacity: number, @@ -236,11 +365,11 @@ export type GET_Responses = { members: GET_Responses[ApiRoutes.GROUP_MEMBER][] } [ApiRoutes.PROJECT_SCORE]: { - score: number | null, + score: number | null, feedback:string | null, projectId: number, groupId: number - }, + }, [ApiRoutes.GROUP_MEMBER]: { email: string name: string @@ -255,72 +384,81 @@ export type GET_Responses = { } [ApiRoutes.GROUP_MEMBERS]: GET_Responses[ApiRoutes.GROUP_MEMBER][] - [ApiRoutes.COURSE_CLUSTERS]: GET_Responses[ApiRoutes.CLUSTER][] - - [ApiRoutes.CLUSTER]: { - clusterId: number; - name: string; - capacity: number; - groupCount: number; - createdAt: Timestamp; - groups: GET_Responses[ApiRoutes.GROUP][] - courseUrl: ApiRoutes.COURSE - } - [ApiRoutes.COURSE]: { - description: string - courseId: number - memberUrl: ApiRoutes.COURSE_MEMBERS - name: string - teacher: CourseTeacher - assistents: CourseTeacher[] - joinUrl: string - archivedAt: Timestamp | null // null if not archived - year: number - createdAt: Timestamp - } - [ApiRoutes.COURSE_MEMBERS]: { - relation: CourseRelation, - user: GET_Responses[ApiRoutes.GROUP_MEMBER] - }[], - [ApiRoutes.USER]: { - courseUrl: string - projects_url: string - url: string - role: UserRole - email: string - id: number - name: string - surname: string - }, - [ApiRoutes.USER_AUTH]: GET_Responses[ApiRoutes.USER], - [ApiRoutes.USER_COURSES]: { - courseId:number, - name:string, - relation: CourseRelation, - memberCount: number, - archivedAt: Timestamp | null, // null if not archived - year: number // Year of the course - url:string - }[], - //[ApiRoutes.PROJECT_GROUP]: GET_Responses[ApiRoutes.CLUSTER_GROUPS][number] - [ApiRoutes.PROJECT_GROUPS]: GET_Responses[ApiRoutes.GROUP][] //GET_Responses[ApiRoutes.PROJECT_GROUP][] + [ApiRoutes.COURSE_CLUSTERS]: GET_Responses[ApiRoutes.CLUSTER][] - [ApiRoutes.PROJECTS]: { - enrolledProjects: {project: GET_Responses[ApiRoutes.PROJECT], status: ProjectStatus}[], - adminProjects: Omit[] - }, + [ApiRoutes.CLUSTER]: { + clusterId: number; + name: string; + capacity: number; + groupCount: number; + createdAt: Timestamp; + groups: GET_Responses[ApiRoutes.GROUP][] + courseUrl: ApiRoutes.COURSE + } + [ApiRoutes.COURSE]: { + description: string + courseId: number + memberUrl: ApiRoutes.COURSE_MEMBERS + name: string + teacher: CourseTeacher + assistents: CourseTeacher[] + joinUrl: string + archivedAt: Timestamp | null // null if not archived + year: number + createdAt: Timestamp + } + [ApiRoutes.COURSE_MEMBERS]: { + relation: CourseRelation, + user: GET_Responses[ApiRoutes.GROUP_MEMBER] + }[], + [ApiRoutes.USER]: { + courseUrl: string + projects_url: string + url: string + role: UserRole + email: string + id: number + name: string + surname: string + }, + [ApiRoutes.USER_AUTH]: GET_Responses[ApiRoutes.USER], + [ApiRoutes.USER_COURSES]: { + courseId: number, + name: string, + relation: CourseRelation, + memberCount: number, + archivedAt: Timestamp | null, // null if not archived + year: number // Year of the course + url: string + }[], + //[ApiRoutes.PROJECT_GROUP]: GET_Responses[ApiRoutes.CLUSTER_GROUPS][number] + [ApiRoutes.PROJECT_GROUPS]: GET_Responses[ApiRoutes.GROUP][] //GET_Responses[ApiRoutes.PROJECT_GROUP][] + + [ApiRoutes.PROJECTS]: { + enrolledProjects: { project: GET_Responses[ApiRoutes.PROJECT], status: ProjectStatus }[], + adminProjects: Omit[] + }, + [ApiRoutes.COURSE_GRADES]: { + projectName: string, + projectUrl: string, + projectId: number, + maxScore: number, + groupFeedback: GET_Responses[ApiRoutes.PROJECT_SCORE] | null + }[] [ApiRoutes.COURSE_GRADES]: { - projectName: string, + projectName: string, projectUrl: string, projectId: number, maxScore: number | null, groupFeedback: GET_Responses[ApiRoutes.PROJECT_SCORE] | null }[] + [ApiRoutes.SUBMISSION_STRUCTURE_FEEDBACK]: string | null // Null if no feedback is given + [ApiRoutes.SUBMISSION_DOCKER_FEEDBACK]: string | null // Null if no feedback is given [ApiRoutes.SUBMISSION_STRUCTURE_FEEDBACK]: string | null // Null if no feedback is given [ApiRoutes.SUBMISSION_DOCKER_FEEDBACK]: string | null // Null if no feedback is given - + [ApiRoutes.SUBMISSION_ARTIFACT]: Blob // returned het artifact als zip } diff --git a/frontend/src/@types/types.d.ts b/frontend/src/@types/types.d.ts index daec4a4b..719ad8de 100644 --- a/frontend/src/@types/types.d.ts +++ b/frontend/src/@types/types.d.ts @@ -1,4 +1,11 @@ +declare module "react" { + interface InputHTMLAttributes extends HTMLAttributes { + webkitdirectory?: string; + directory?:string + mozdirectory?: string + } +} diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json index 7dc2925d..baab0119 100644 --- a/frontend/src/i18n/en/translation.json +++ b/frontend/src/i18n/en/translation.json @@ -88,7 +88,7 @@ "submit": "Submit", "back": "Cancel", "uploadAreaTitle": "Click or drag file to this area to upload", - "uploadAreaSubtitle": "Maximum file size is 10MB", + "uploadAreaSubtitle": "Maximum file size is 100MB", "deadlinePassed": "Deadline passed", "downloadSubmissions": "Download all submissions", "group": "Group", @@ -96,9 +96,12 @@ "feedback": "Feedback", "groupEmpty": "No members in this group", "testFailed": "Tests failed", + "uploadDirectory": "Upload directory", + "submission": "Submission", "structureFailed": "Structure tests failed", "passed": "Passed", "notSubmitted": "Not submitted", + "submitSuccess": "Submission successful", "submissionTime": "Submission time", "noSubmissions": "No submissions", "loadingSubmissions": "Loading submissions...", @@ -245,4 +248,4 @@ "write": "Write", "preview": "Preview" } -} \ No newline at end of file +} diff --git a/frontend/src/i18n/nl/translation.json b/frontend/src/i18n/nl/translation.json index dfb824c2..c52c1a0d 100644 --- a/frontend/src/i18n/nl/translation.json +++ b/frontend/src/i18n/nl/translation.json @@ -90,7 +90,9 @@ "addFiles": "Bestanden toevoegen", "submit": "Indienen", "uploadAreaTitle": "Bestanden slepen of klikken om bestanden toe te voegen", - "uploadAreaSubtitle": "Maximum bestandsgrootte is 10MB", + "uploadAreaSubtitle": "Maximum bestandsgrootte is 100MB", + "deadlinePassed": "Deadline is verstreken", + "uploadDirectory": "Folder uploaden", "deadlinePassed": "Deadline verstreken", "downloadSubmissions": "Download alle indieningen", "group": "Groep", @@ -100,6 +102,8 @@ "testFailed": "Testen gefaald", "structureFailed": "Structuurtesten gefaald", "passed": "Geslaagd", + "submitSuccess": "Indiening succesvol", + "notSubmitted": "Niet ingediend", "submissionTime": "Indieningstijd", "noSubmissions": "Geen indieningen", diff --git a/frontend/src/pages/submission/components/SubmissionCard.tsx b/frontend/src/pages/submission/components/SubmissionCard.tsx index 5e4022d1..f218535a 100644 --- a/frontend/src/pages/submission/components/SubmissionCard.tsx +++ b/frontend/src/pages/submission/components/SubmissionCard.tsx @@ -1,130 +1,155 @@ -import { Card, Spin, theme, Input, Button, Typography } from "antd" -import { useTranslation } from "react-i18next" -import { GET_Responses } from "../../../@types/requests" -import { ApiRoutes } from "../../../@types/requests" -import { ArrowLeftOutlined } from "@ant-design/icons" -import { useNavigate } from "react-router-dom" +import {Card, Spin, theme, Input, Button, Typography} from "antd" +import {useTranslation} from "react-i18next" +import {GET_Responses} from "../../../@types/requests" +import {ApiRoutes} from "../../../@types/requests" +import {ArrowLeftOutlined} from "@ant-design/icons" +import {useNavigate} from "react-router-dom" import "@fontsource/jetbrains-mono" -import { useEffect, useState } from "react" +import {useEffect, useState} from "react" import apiCall from "../../../util/apiFetch" export type SubmissionType = GET_Responses[ApiRoutes.SUBMISSION] -const SubmissionCard: React.FC<{ submission: SubmissionType }> = ({ submission }) => { - const { token } = theme.useToken() - const { t } = useTranslation() - const [structureFeedback, setStructureFeedback] = useState(null) - const [dockerFeedback, setDockerFeedback] = useState(null) - const navigate = useNavigate() - useEffect(() => { - if (!submission.dockerAccepted) apiCall.get(submission.dockerFeedbackUrl).then((res) => setDockerFeedback(res.data ? res.data : "")) - if (!submission.structureAccepted) apiCall.get(submission.structureFeedbackUrl).then((res) => setStructureFeedback(res.data ? res.data : "")) - }, [submission.dockerFeedbackUrl, submission.structureFeedbackUrl]) +const SubmissionCard: React.FC<{ submission: SubmissionType }> = ({submission}) => { + const {token} = theme.useToken() + const {t} = useTranslation() + const [structureFeedback, setStructureFeedback] = useState(null) + const [dockerFeedback, setDockerFeedback] = useState(null) + const navigate = useNavigate() + useEffect(() => { + if (!submission.dockerAccepted) apiCall.get(submission.dockerFeedbackUrl).then((res) => setDockerFeedback(res.data ? res.data : "")) + if (!submission.structureAccepted) apiCall.get(submission.structureFeedbackUrl).then((res) => setStructureFeedback(res.data ? res.data : "")) + }, [submission.dockerFeedbackUrl, submission.structureFeedbackUrl]) - const downloadSubmission = async () => { - //TODO: testen of dit wel echt werkt - try { - const fileContent = await apiCall.get(submission.fileUrl) - console.log(fileContent) - const blob = new Blob([fileContent.data], { type: "text/plain" }) - const url = URL.createObjectURL(blob) - const link = document.createElement("a") - link.href = url - link.download = "indiening.zip" - document.body.appendChild(link) - link.click() - URL.revokeObjectURL(url) - document.body.removeChild(link) - } catch (err) { - // TODO: handle error + const downloadSubmission = async () => { + try { + const response = await apiCall.get(submission.fileUrl, undefined, undefined, { + responseType: 'blob', + transformResponse: [(data) => data], + }); + console.log(response); + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + const contentDisposition = response.headers['content-disposition']; + console.log(contentDisposition); + let fileName = 'file.zip'; // default filename + if (contentDisposition) { + const fileNameMatch = contentDisposition.match(/filename=([^;]+)/); + console.log(fileNameMatch); + if (fileNameMatch && fileNameMatch[1]) { + fileName = fileNameMatch[1]; // use the filename from the headers + } + } + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); + } catch (err) { + console.error(err); + } } - } - return ( - + return ( + - {t("submission.submission")} + {t("submission.submission")} - } - > - {t("submission.submittedFiles")} + } + > + {t("submission.submittedFiles")} -
    -
  • - -
  • -
+
    +
  • + +
  • +
- {t("submission.structuretest")} + {t("submission.structuretest")} -
    -
  • - {submission.structureAccepted ? t("submission.status.accepted") : t("submission.status.failed")} - {submission.structureAccepted ? null : ( -
    - {structureFeedback === null ? ( - - ) : ( - - )} -
    - )} -
  • -
+
    +
  • + {submission.structureAccepted ? t("submission.status.accepted") : t("submission.status.failed")} + {submission.structureAccepted ? null : ( +
    + {structureFeedback === null ? ( + + ) : ( + + )} +
    + )} +
  • +
- {t("submission.dockertest")} + {t("submission.dockertest")} -
    -
  • - {submission.dockerAccepted ? t("submission.status.accepted") : t("submission.status.failed")} - {submission.dockerAccepted ? null : ( -
    - {dockerFeedback === null ? ( - - ) : ( - - )} -
    - )} -
  • -
-
- ) +
    +
  • + {submission.dockerAccepted ? t("submission.status.accepted") : t("submission.status.failed")} + {submission.dockerAccepted ? null : ( +
    + {dockerFeedback === null ? ( + + ) : ( + + )} +
    + )} +
  • +
+
+ ) } export default SubmissionCard diff --git a/frontend/src/pages/submit/Submit.tsx b/frontend/src/pages/submit/Submit.tsx index 5a5ec4f5..b5bab401 100644 --- a/frontend/src/pages/submit/Submit.tsx +++ b/frontend/src/pages/submit/Submit.tsx @@ -1,74 +1,117 @@ -import { Affix, Button, Card, Col, Form, Row, Typography } from "antd" -import { useTranslation } from "react-i18next" +import {Affix, Button, Card, Col, Form, Row, Typography} from "antd" +import {useTranslation} from "react-i18next" import SubmitForm from "./components/SubmitForm" import SubmitStructure from "./components/SubmitStructure" -import { useNavigate } from "react-router-dom" +import {useNavigate, useParams} from "react-router-dom" +import React, {useState, useRef} from 'react'; +import apiCall from "../../util/apiFetch"; +import {ApiRoutes} from "../../@types/requests.d"; +import JSZip from 'jszip'; +import { Popconfirm, message } from 'antd'; +import {AppRoutes} from "../../@types/routes"; +import submission from "../submission/Submission"; const Submit = () => { - const { t } = useTranslation() - const [form] = Form.useForm() + const {t} = useTranslation() + const [form] = Form.useForm() + const {projectId, courseId} = useParams<{ projectId: string, courseId: string}>() + const [fileAdded, setFileAdded] = useState(false); + const navigate = useNavigate() - const navigate = useNavigate() + const onSubmit = async (values: any) => { + console.log("Received values of form: ", values) + const files = values.files.map((file: any) => file.originFileObj); + if (files.length === 0) { + console.error("No files selected") + return + } + console.log(files); + const formData = new FormData() + const zip = new JSZip(); + files.forEach((file: any) => { + zip.file(file.webkitRelativePath || file.name, file); + }); + const content = await zip.generateAsync({type: "blob"}); + formData.append("file", content, "files.zip"); - return ( - <> -
- - - - - - + if (!projectId) return; + const response = await apiCall.post(ApiRoutes.PROJECT_SUBMIT, formData, {id: projectId}) + console.log(response) + const submissionId:string = response.data.submissionId.toString(); + if (response.status === 200) { // Check if the submission was successful + message.success(t("project.submitSuccess")); + } + else { + message.error(t("project.submitError")); + } + if (courseId != null && submissionId != null) { + navigate(AppRoutes.SUBMISSION.replace(':courseId', courseId).replace(':projectId', projectId).replace(':submissionId', submissionId)); + }else{ + console.log(projectId) + console.log(courseId) + console.log(submissionId) + message.error(t("project.submitError")); + } + } - - - - - - - - - - - - -
- - - ) + return ( + <> +
+ + + + + + + + + + + + + + + + + + +
+ + + ) } -export default Submit +export default Submit \ No newline at end of file diff --git a/frontend/src/pages/submit/components/SubmitForm.tsx b/frontend/src/pages/submit/components/SubmitForm.tsx index 32c6f9ad..0cdd2993 100644 --- a/frontend/src/pages/submit/components/SubmitForm.tsx +++ b/frontend/src/pages/submit/components/SubmitForm.tsx @@ -1,51 +1,251 @@ -import { InboxOutlined } from "@ant-design/icons" -import { Form, FormInstance, Upload } from "antd" -import { FC } from "react" -import { useTranslation } from "react-i18next" +import {InboxOutlined} from "@ant-design/icons" +import {Form, FormInstance, Upload} from "antd" +import {FC, useRef, useState} from "react" +import {useTranslation} from "react-i18next" +import {Button} from "antd"; +import {Tree} from 'antd'; +import {CloseOutlined} from '@ant-design/icons'; +import { DataNode } from "antd/es/tree"; +type TreeNode = { + type: string; + title: string; + key: string; + children: TreeNode[]; +}; -const SubmitForm:FC<{form:FormInstance}> = ({form}) => { +const SubmitForm: FC<{ + form: FormInstance, + setFileAdded: (added: boolean) => void, + onSubmit: (values: any) => void +}> = ({form, setFileAdded, onSubmit}) => { - const {t} = useTranslation() - const normFile = (e: any) => { - console.log("Upload event:", e) - if (Array.isArray(e)) { - return e + const {t} = useTranslation() + const directoryInputRef = useRef(null); + const [directoryTree, setDirectoryTree] = useState([]); + + const normFile = (e: any) => { + console.log("Upload event:", e) + if (Array.isArray(e)) { + return e + } + return e?.fileList + } + + const onFinish = (values: any) => { + onSubmit(values); + }; + + + const removeEmptyParentNodes = (nodes: TreeNode[]) => { + for (let i = nodes.length - 1; i >= 0; i--) { + if (nodes[i].type === 'folder') { + if (!nodes[i].children || nodes[i].children.length === 0) { + nodes.splice(i, 1); + } else { + removeEmptyParentNodes(nodes[i].children); + } + } + } + }; + + const removeNode = (key: string) => { + const removeNodeRecursive = (nodes: TreeNode[]): boolean => { + for (let i = 0; i < nodes.length; i++) { + if (nodes[i].key === key) { + nodes.splice(i, 1); + return true; + } else if (nodes[i].children) { + const childRemoved = removeNodeRecursive(nodes[i].children); + if (childRemoved) { + removeEmptyParentNodes(nodes); + return true; + } + } + } + return false; + }; + + const newDirectoryTree = [...directoryTree]; + removeNodeRecursive(newDirectoryTree); + setDirectoryTree(newDirectoryTree); + + + const newFileList = form.getFieldValue('files').filter((file: any) => !file.uid.startsWith(key)); + form.setFieldsValue({ + files: newFileList + }); + + if (newDirectoryTree.length === 0) { + setFileAdded(false); + } + }; + const markFolders = (nodes: TreeNode[]) => { + for (let i = 0; i < nodes.length; i++) { + if (nodes[i].children && nodes[i].children.length > 0) { + nodes[i].type = 'folder'; + markFolders(nodes[i].children); + } + } + }; + const onDirectoryUpload = (event: React.ChangeEvent) => { + const files = event.target.files; + if (files) { + const currentFileList = form.getFieldValue('files') || []; + const newDirectoryTree: TreeNode[] = [...directoryTree]; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + currentFileList.push({ + uid: file.webkitRelativePath, + name: file.name, + status: 'done', + originFileObj: file + }); + + + const pathParts = file.webkitRelativePath.split('/'); + let currentNode = newDirectoryTree; + for (let j = 0; j < pathParts.length; j++) { + let foundNode = currentNode.find(node => node.title === pathParts[j]); + if (!foundNode) { + foundNode = { + title: pathParts[j], + key: pathParts.slice(0, j + 1).join('/'), + children: [], + type: 'file' + }; + currentNode.push(foundNode); + } + currentNode = foundNode.children; + } + } + markFolders(newDirectoryTree); + + form.setFieldsValue({ + files: currentFileList + }); + setDirectoryTree(newDirectoryTree); + setFileAdded(true); + } } - return e?.fileList - } - - const onFinish = (values: any) => { - console.log("Received values of form: ", values) - - // TODO: make api call - } - - return ( -
- - - -

- -

-

{t("project.uploadAreaTitle")}

-

{t("project.uploadAreaSubtitle")}

-
-
-
- ) + const renderTreeNodes = (data: TreeNode[]): DataNode[] => + data.map((item) => { + if (item.children?.length) { + return { + title: ( + + {item.title} + + + + ) } -export default SubmitForm +export default SubmitForm \ No newline at end of file diff --git a/frontend/src/util/apiFetch.ts b/frontend/src/util/apiFetch.ts index d542607d..8985fb15 100644 --- a/frontend/src/util/apiFetch.ts +++ b/frontend/src/util/apiFetch.ts @@ -8,8 +8,8 @@ const serverHost = window.location.origin.includes("localhost") ? "http://local let accessToken: string | null = null let tokenExpiry: Date | null = null -export type ApiMethods = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" -export type ApiCallPathValues = {[param: string]: string | number} + +type ApiCallPathValues = {[param: string]: string | number} /** * * @param method @@ -21,7 +21,7 @@ export type ApiCallPathValues = {[param: string]: string | number} * const newCourse = await apiFetch("POST", ApiRoutes.COURSES, { name: "New Course" }); * */ -export async function apiFetch(method: ApiMethods, route: string, body?: any, pathValues?:ApiCallPathValues): Promise> { +async function apiFetch(method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", route: string, body?: any, pathValues?:ApiCallPathValues, headers?: {[header: string]: string}, config?: AxiosRequestConfig): Promise> { const account = msalInstance.getActiveAccount() if (!account) { @@ -47,30 +47,32 @@ export async function apiFetch(method: ApiMethods, route: string, body?: any, pa tokenExpiry = response.expiresOn // convert expiry time to JavaScript Date } - const headers = { + const defaultHeaders = { Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", + "Content-Type": body instanceof FormData ? undefined : "application/json", } + const finalHeaders = headers ? {...defaultHeaders, ...headers} : defaultHeaders; + const url = new URL(route, serverHost) - const config: AxiosRequestConfig = { + + const finalConfig: AxiosRequestConfig = { method: method, url: url.toString(), - headers: headers, - data: body, + headers: finalHeaders, + data: body instanceof FormData ? body : JSON.stringify(body), + ...config, // spread the config object to merge it with the existing configuration } - - - return axios(config) + return axios(finalConfig) } const apiCall = { - get: async (route: T, pathValues?:ApiCallPathValues) => apiFetch("GET", route,undefined,pathValues) as Promise>, - post: async (route: T, body: POST_Requests[T], pathValues?:ApiCallPathValues) => apiFetch("POST", route, body,pathValues) as Promise>, - put: async (route: T, body: PUT_Requests[T], pathValues?:ApiCallPathValues) => apiFetch("PUT", route, body,pathValues) as Promise>, - delete: async (route: T, body: DELETE_Requests[T], pathValues?:ApiCallPathValues) => apiFetch("DELETE", route, body,pathValues), - patch: async (route: T, body: Partial, pathValues?:ApiCallPathValues) => apiFetch("PATCH", route, body,pathValues) as Promise>, + get: async (route: T, pathValues?:ApiCallPathValues, headers?: {[header: string]: string}, config?: AxiosRequestConfig) => apiFetch("GET", route, undefined, pathValues, headers, config) as Promise>, + post: async (route: T, body: POST_Requests[T] | FormData, pathValues?:ApiCallPathValues, headers?: {[header: string]: string}) => apiFetch("POST", route, body, pathValues, headers) as Promise>, + put: async (route: T, body: PUT_Requests[T], pathValues?:ApiCallPathValues, headers?: {[header: string]: string}) => apiFetch("PUT", route, body, pathValues, headers) as Promise>, + delete: async (route: T, body: DELETE_Requests[T], pathValues?:ApiCallPathValues, headers?: {[header: string]: string}) => apiFetch("DELETE", route, body, pathValues, headers), + patch: async (route: T, body: Partial, pathValues?:ApiCallPathValues, headers?: {[header: string]: string}) => apiFetch("PATCH", route, body, pathValues, headers) as Promise>, } const apiCallInit = async () => {