From 01cd42f2916b7561407e8b6ad0a48da9942fd7ef Mon Sep 17 00:00:00 2001 From: Ewout Verlinde Date: Thu, 23 May 2024 22:00:18 +0200 Subject: [PATCH] Release (#468) * chore: changed production docker (#432) * chore: changed production docker * chore: modified tests as well * chore: celery * fix: Admin panel fix (#427) * chore: make /admin/ redirect to /admin/users/ #356 * chore: make change size depending on screen size #356 * chore: replace "public" ToggleButton by a ToggleSwitch #356 * chore: add "remove" button #356 * chore: visual layout remove button for selected docker images #356 * chore: make multiRemove button disabled when nothing's selected #356 * chore: add selectedItems variable, so I can reach that when the objects are actually being removed #356 * chore: error message for case saving when no item is selected (shouldn't actually happen ever) #356 * chore: not able to upload when there is no name for docker image #356 * chore: make selection an option instead of mandatory for LazyDataTable #356 * chore: documentation UsersView #356 * chore: documentation DockerImagesView #356 * chore: translations + try at allowing and file upload after 1st upload #356 * chore: change to list when uploading #356 * chore: after dockerImage is removed separately, remove it from list of selectedItems #356 * chore: implement multiRemove of docker images #356 * fix: lint fix #356 * fix: correct "or" filter for functions of user to an "and" filter #356 * docs: extra documentation of DockerImagesView.vue #356 * fix: replace Dialog by ConfirmDialog #356 * style: linting fix #356 * docs: extra doc for DockerImagesView.vue #356 * chore: translation corrections #356 * chore: joining of functions (fillCreators and fillDestroyers) for a code execution speedup (hopefully) #356 * style: lint fix #356 * chore: change word in header in admin panel from "ypovoli" to "admin" #356 * chore: translation fixes #356 * chore: forgot to remove stuff in Dutch translations #356 * chore: no extra checks fix * Adds all possible error messages to submission fixtures * Cleanup translations (#442) * fix: show toggle button project list also when there are only projects in the past * fix: cleanup translations * fix: default submission status argument * chore: remove unused translations * chore: cleanup translations * fix: linting * fix: no leaving single groups (#441) * chore: send notifications * chore: simplify extra checks count * fix: linting errors * --- updated-dependencies: - dependency-name: requests dependency-type: direct:production ... Signed-off-by: dependabot[bot] * feat: treeview (wip) * feat: structure check tree view * chore: added files * chore: backend for structure checks * feat: working tree builder * feat: working tree builder * chore: tests * chore: fixes and improvements * chore: frontend tests (wip) * chore: moved notification sender * chore: linting * chore: frontend tests (wip) * chore: cleanup * fix: docker image validation * chore: PR comments * fix: backend tests * Toast messages upon creation of objects (#437) * chore: course creation toast message #407 * chore: project creation toast message #407 * chore: add Dutch translations #407 * chore: submission creation success toast message #407 * chore: submission creation success toast message dutch #407 * chore: remove punctuation at end of sentence #407 * chore: restructure imports #407 * chore: helper function that also shows a toast message upon successful creation #407 * chore: make general for now #407 * chore: abstract and give type to fill in correct type in toast message #407 * chore: log errors when creating with custom message #407 * chore: actually remove try catch block, because it's not necessary #407 * chore: add punctuation at end of full sentences #407 * chore: replace a fromJSON #407 * chore: try to fix test #407 * style: lint fix #407 * test: fix test #407 * style: lint fix #407 * fix: create serializer * fix: toggle show artifact * feat: project views, footer (#452) * feat: project views * fix: project permissions * fix: backend tests * chore: project views * fix: permissions * fix: linting * chore: fixes * chore: added socials * chore: better error messages (#446) * chore: make services throw errors to caller if requested * chore: make services throw errors to caller if requested * chore: place init?:boolean back * chore: rewrite landers code * chore: fix linter * chore: fix linter * chore: fix linter * chore: fix linter * chore: fix linter * chore: fix linter * chore: fix linter * chore: fix linter * chore: first part of merge * fix: project service * chore: linting * chore: fix help * chore: merge & toast example --------- Co-authored-by: EwoutV * test: Course test (#450) * chore: add loads of changes to login mechanism for testing (possible options are admin, student, and all roles at the same time) #434 * chore: login users beforehand (might delete this in the future actually) #434 * chore: trying to fix adding data before tests #434 * chore: more trial and error #434 * chore: struggling.. #434 * chore: so close, but yet so far #407 * chore: fix location of preprocessor #407 * chore: remove unnecessary node events #434 * chore: test.sh script now deletes database and fills with realistic fixtures #434 * test: automatically login all users, before every test file, so that they're available, but also automatically logout after every test #434 * test: set path to supportFile correctly cypress #434 * test: removing logging out for cleaning up #434 * test: add professor login endpoint #434 * test: cleanup #434 * test: course make and course enroll test with id's applied where necessary #434 * test: forgot to actually commit the test file itself #434 * style: lint fix #434 * chore: move filling db to outside --------- Co-authored-by: Topvennie * chore: remove option private docker images * fix: automatically joining individual projects * fix: translations * fix: linting * chore: confirm dialog before deletion * fix: vue validate error + form selection * Single submission view (#453) * chore: better submission status + favicon (#454) * chore: extra status fields * chore: submission status better than ever * chore: linting * chore: fix tests * chore: favicon * chore: better favicon * fix: typos in realistic fixture * chore: switch project name and ourse name positions * fix: cleanup * chore: clearer submission status * fix: Create toast fix (#456) * chore: remove createToast in helpers.ts and leave showing of creation success messages up to the views * chore: update jdocs of helpers.ts functions to include the selfProcessError parameter * chore: edit of user on admin panel shows success message when successful * chore: edit of docker image on admin panel shows success message when successful * style: lint fix * fix: messed up SubmissionView.vue during rebase, have just completely copied it from development now * fix: display success message when uploading from SubmissionsView.vue * style: lint fix * chore: restore config.mts file: * chore: contents of config.mts * fix: no submissions before start (#440) * fix: no submissions before start * chore: move project startdate * chore: demo * chore: demo * chore: allow joining group after start * chore: git merge * fix: empty docker image * chore: typo * chore: modify gitignore * fix: deadline (#461) * chore: better colors for submission status * chore: more descriptive text * fix: select other months' date (#462) * feat: notifications frontend (#460) * chore: notifications (wip) * chore: notifications (wip) * feat: notifications in frontend * chore: added files * chore: notification creation * fix: fixed some weird shizzles * chore: notifications * chore: disabled load more button when no notifications * chore: linting * chore: removed vitepress cache * fix: score creation * chore: linting --------- Co-authored-by: Topvennie * chore: fix help * fix (#467) * fix: assets (#466) * fix: small submission fixes (#465) * fix translations, fix links, fix errors * fix: delete garbage * fix: small changes * chore: fixing submission * fix: submission permissions * chore: linting * feat: notifications frontend (#460) * chore: notifications (wip) * chore: notifications (wip) * feat: notifications in frontend * chore: added files * chore: notification creation * fix: fixed some weird shizzles * chore: notifications * chore: disabled load more button when no notifications * chore: linting * chore: removed vitepress cache * fix: score creation * chore: linting --------- Co-authored-by: Topvennie * fix: translations --------- Co-authored-by: lander Co-authored-by: Topvennie Co-authored-by: Bram Meir --------- Signed-off-by: dependabot[bot] Co-authored-by: Vincent Vallaeys Co-authored-by: Brent Silkyn <160223373+bsilkyn@users.noreply.github.com> Co-authored-by: francis Co-authored-by: Bram Meir <159529490+BramMeir@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Francis Vauterin <159532420+francisvaut@users.noreply.github.com> Co-authored-by: Bram Meir Co-authored-by: Tybo Verslype <97916632+tyboro2002@users.noreply.github.com> Co-authored-by: Lander Maes <67026331+DeLany123@users.noreply.github.com> Co-authored-by: tyboro2002 Co-authored-by: lander --- .gitignore | 5 +- backend/.tool-versions | 1 + backend/api/fixtures/realistic/realistic.yaml | 331 +++++++++++++- backend/api/locale/en/LC_MESSAGES/django.po | 52 ++- backend/api/locale/nl/LC_MESSAGES/django.po | 58 +-- backend/api/logic/parse_zip_files.py | 6 +- .../commands/teacher_join_course.py | 35 -- backend/api/migrations/0019_feedback.py | 26 ++ .../migrations/0024_merge_20240507_1230.py | 14 + .../migrations/0026_merge_20240518_1058.py | 14 + backend/api/models/feedback.py | 38 ++ backend/api/models/project.py | 11 +- backend/api/permissions/course_permissions.py | 9 +- .../api/permissions/feedback_permissions.py | 17 + backend/api/permissions/group_permissions.py | 49 ++- .../api/permissions/project_permissions.py | 53 ++- backend/api/permissions/role_permissions.py | 7 +- .../api/permissions/submission_permissions.py | 9 +- backend/api/seeders/seeder.py | 25 +- backend/api/serializers/checks_serializer.py | 133 +++--- backend/api/serializers/course_serializer.py | 60 +-- .../api/serializers/feedback_serializer.py | 25 ++ backend/api/serializers/fields/__init__.py | 0 .../fields/expandable_hyperlinked_field.py | 34 ++ backend/api/serializers/group_serializer.py | 44 +- backend/api/serializers/project_serializer.py | 67 +-- .../api/serializers/submission_serializer.py | 7 + backend/api/signals.py | 12 + backend/api/tasks/docker_image.py | 19 +- backend/api/tasks/extra_check.py | 28 +- backend/api/tasks/structure_check.py | 15 + backend/api/tests/test_course.py | 2 +- backend/api/tests/test_feedback.py | 0 backend/api/tests/test_group.py | 12 +- backend/api/tests/test_project.py | 69 ++- backend/api/urls.py | 3 + backend/api/views/course_view.py | 13 +- backend/api/views/docker_view.py | 8 + backend/api/views/feedback_view.py | 12 + backend/api/views/group_view.py | 57 ++- backend/api/views/project_view.py | 92 +++- backend/api/views/submission_view.py | 47 +- backend/api/views/user_view.py | 26 +- backend/authentication/serializers.py | 12 +- backend/authentication/views.py | 56 ++- .../projects/0/0/checks/generate_gibberish.sh | 2 +- .../fixtures/realistic/realistic.yaml | 45 ++ .../locale/en/LC_MESSAGES/django.po | 35 ++ .../locale/nl/LC_MESSAGES/django.po | 35 ++ backend/notifications/logic.py | 19 +- .../0002_alter_notification_user.py | 21 + backend/notifications/models.py | 48 ++- backend/notifications/serializers.py | 60 +-- backend/notifications/signals.py | 33 +- backend/poetry.lock | 10 +- backend/pyproject.toml | 2 +- backend/ypovoli/settings.py | 65 ++- development.sh | 3 +- docs/.vitepress/config.mts | 12 +- docs/en/admin-examples.md | 2 +- docs/en/api-examples.md | 49 --- docs/en/index.md | 11 +- docs/en/markdown-examples.md | 85 ---- docs/en/student-examples.md | 5 +- docs/nl/api-examples.md | 49 --- docs/nl/index.md | 11 +- docs/nl/markdown-examples.md | 85 ---- docs/nl/student-examples.md | 5 +- frontend/cypress.config.js | 6 + frontend/cypress/support/e2e.ts | 42 +- frontend/index.html | 2 +- frontend/package-lock.json | 16 +- frontend/package.json | 4 +- frontend/src/assets/img/favicon.ico | Bin 0 -> 1150 bytes frontend/src/assets/lang/app/en.json | 208 ++++++--- frontend/src/assets/lang/app/nl.json | 225 ++++++---- frontend/src/assets/lang/info/en.json | 9 +- .../base/components/overlay/_tooltip.scss | 10 +- frontend/src/components/Loading.vue | 18 + .../src/components/admin/LazyDataTable.vue | 37 +- .../components/courses/CourseDetailCard.vue | 38 +- .../components/courses/CourseDetailList.vue | 2 +- .../src/components/courses/CourseForm.vue | 136 ++++++ .../components/courses/CourseGeneralList.vue | 49 +-- .../components/courses/ShareCourseButton.vue | 16 +- .../students/StudentCourseJoinButton.vue | 1 + frontend/src/components/forms/Editor.vue | 13 +- .../src/components/forms/ErrorMessage.vue | 8 +- .../src/components/help/HelpBaseLayout.vue | 28 -- frontend/src/components/help/HelpHeader.vue | 208 --------- .../TeacherAssistantCard.vue | 9 +- .../TeacherAssistantList.vue | 0 .../TeacherAssistantSearch.vue | 3 + .../TeacherAssistantUpdateButton.vue | 0 .../buttons/CourseRoleAddButton.vue | 2 +- .../buttons/LeaveCourseButton.vue | 0 frontend/src/components/layout/Footer.vue | 25 -- .../notifications/NotificationCard.vue | 39 ++ .../notifications/NotificationsOverlay.vue | 74 ++++ .../NotificationsScrollPanel.vue | 35 ++ .../components/projects/ProjectDetailCard.vue | 6 +- ...on.vue => ProjectDownloadGradesButton.vue} | 39 +- ...pload.vue => ProjectExtraChecksEditor.vue} | 231 +++++----- .../src/components/projects/ProjectForm.vue | 241 +++++++++++ .../src/components/projects/ProjectList.vue | 26 +- .../projects/ProjectStructureEditor.vue | 261 ++++++++++++ .../components/projects/SubmissionCard.vue | 2 +- .../GroupChooseCard.vue} | 40 +- .../GroupJoinedCard.vue} | 13 +- .../projects/groups/GroupsManageTable.vue | 131 ++++++ .../components/submissions/AllSubmission.vue | 84 ++-- .../ProjectMeter.vue | 60 ++- .../components/submissions/SubmissionCard.vue | 66 +++ .../src/composables/services/admin.service.ts | 26 +- .../composables/services/assistant.service.ts | 82 +++- .../composables/services/course.service.ts | 114 +++-- .../composables/services/docker.service.ts | 64 ++- .../services/extra_checks.service.ts | 36 +- .../composables/services/faculty.service.ts | 33 +- .../composables/services/feedback.service.ts | 57 +++ .../src/composables/services/group.service.ts | 58 ++- frontend/src/composables/services/helpers.ts | 162 ++++--- .../services/notification.service.ts | 43 ++ .../composables/services/project.service.ts | 101 ++--- .../services/structure_check.service.ts | 54 ++- .../composables/services/student.service.ts | 102 +++-- .../services/submission.service.ts | 38 +- .../services/submission_status.service.ts | 6 +- .../composables/services/teacher.service.ts | 82 +++- .../src/composables/services/users.service.ts | 37 +- frontend/src/config/endpoints.ts | 14 +- frontend/src/main.scss | 7 +- frontend/src/router/router.ts | 70 +-- frontend/src/store/authentication.store.ts | 4 +- frontend/src/store/notification.store.ts | 57 +++ frontend/src/test/e2e/course.cy.ts | 33 ++ frontend/src/test/e2e/login.cy.ts | 6 +- .../test/unit/services/course_service.test.ts | 365 ++++++++-------- .../test/unit/services/group_service.test.ts | 2 +- .../unit/services/project_service.test.ts | 2 +- frontend/src/test/unit/services/setup/data.ts | 8 +- .../unit/services/setup/delete_handlers.ts | 13 +- .../test/unit/services/setup/post_handlers.ts | 13 +- .../unit/services/structure_check.test.ts | 167 ++++---- frontend/src/test/unit/types/course.test.ts | 34 +- .../test/unit/types/extraCheckResults.test.ts | 59 ++- frontend/src/test/unit/types/group.test.ts | 2 +- frontend/src/test/unit/types/project.test.ts | 2 +- .../test/unit/types/structureCheck.test.ts | 53 +-- .../unit/types/structureCheckResult.test.ts | 55 ++- .../test/unit/types/submissionStatus.test.ts | 49 +-- frontend/src/test/unit/types/teacher.test.ts | 34 +- frontend/src/types/ApiResponse.ts | 1 + frontend/src/types/Course.ts | 93 ++-- frontend/src/types/DockerImage.ts | 20 +- frontend/src/types/ExtraCheck.ts | 34 +- frontend/src/types/Faculty.ts | 11 +- frontend/src/types/Feedback.ts | 25 ++ frontend/src/types/FileExtension.ts | 22 +- frontend/src/types/Group.ts | 38 +- frontend/src/types/Notification.ts | 39 ++ frontend/src/types/Project.ts | 74 ++-- frontend/src/types/StructureCheck.ts | 119 +++++- frontend/src/types/SubmisionStatus.ts | 23 +- .../src/types/submission/ErrorMessageEnum.ts | 15 + .../src/types/submission/ExtraCheckResult.ts | 32 +- .../types/submission/StructureCheckResult.ts | 31 +- frontend/src/types/submission/Submission.ts | 98 +++-- frontend/src/types/users/Student.ts | 8 +- frontend/src/types/users/Teacher.ts | 13 +- frontend/src/types/users/User.ts | 35 +- frontend/src/views/admin/AdminView.vue | 9 - frontend/src/views/admin/DockerImagesView.vue | 236 +++++++--- frontend/src/views/admin/UsersView.vue | 111 +++-- .../src/views/authentication/LoginView.vue | 4 +- .../src/views/authentication/VerifyView.vue | 15 +- frontend/src/views/calendar/CalendarView.vue | 184 ++++---- frontend/src/views/courses/CourseView.vue | 40 +- .../src/views/courses/CreateCourseView.vue | 184 ++------ frontend/src/views/courses/JoinCourseView.vue | 6 +- .../src/views/courses/SearchCourseView.vue | 26 +- .../src/views/courses/UpdateCourseView.vue | 212 ++------- .../courses/roles/AssistantCourseView.vue | 39 +- .../views/courses/roles/StudentCourseView.vue | 49 ++- .../views/courses/roles/TeacherCourseView.vue | 31 +- .../src/views/dashboard/DashboardView.vue | 2 +- .../roles/AssistantDashboardView.vue | 115 ++--- .../dashboard/roles/StudentDashboardView.vue | 69 +-- .../dashboard/roles/TeacherDashboardView.vue | 156 ++++--- frontend/src/views/help/HelpDashboard.vue | 58 --- .../src/{components => views}/layout/Body.vue | 2 +- frontend/src/views/layout/Footer.vue | 64 +++ .../{components => views}/layout/Title.vue | 0 .../layout/admin/AdminHeader.vue | 4 +- .../layout/admin/AdminLayout.vue | 6 +- .../layout/admin/AdminSidebar.vue | 2 +- .../layout/base/BaseHeader.vue | 47 +- .../layout/base/BaseLayout.vue | 6 +- .../src/views/projects/CreateProjectView.vue | 360 +++++----------- frontend/src/views/projects/ProjectView.vue | 68 +-- frontend/src/views/projects/ProjectsView.vue | 4 +- .../src/views/projects/UpdateProjectView.vue | 403 ++++++------------ .../projects/roles/StudentProjectView.vue | 205 ++++++--- .../projects/roles/TeacherProjectView.vue | 167 ++++++-- .../src/views/submissions/SubmissionView.vue | 298 ++++++++----- .../src/views/submissions/SubmissionsView.vue | 152 +++++++ test.sh | 6 + test.yml | 2 + 208 files changed, 6854 insertions(+), 4131 deletions(-) create mode 100644 backend/.tool-versions delete mode 100644 backend/api/management/commands/teacher_join_course.py create mode 100644 backend/api/migrations/0019_feedback.py create mode 100644 backend/api/migrations/0024_merge_20240507_1230.py create mode 100644 backend/api/migrations/0026_merge_20240518_1058.py create mode 100644 backend/api/models/feedback.py create mode 100644 backend/api/permissions/feedback_permissions.py create mode 100644 backend/api/serializers/feedback_serializer.py create mode 100644 backend/api/serializers/fields/__init__.py create mode 100644 backend/api/serializers/fields/expandable_hyperlinked_field.py create mode 100644 backend/api/tests/test_feedback.py create mode 100644 backend/api/views/feedback_view.py create mode 100644 backend/notifications/migrations/0002_alter_notification_user.py mode change 100755 => 100644 docs/.vitepress/config.mts delete mode 100644 docs/en/api-examples.md delete mode 100644 docs/en/markdown-examples.md delete mode 100644 docs/nl/api-examples.md delete mode 100644 docs/nl/markdown-examples.md create mode 100644 frontend/src/assets/img/favicon.ico create mode 100644 frontend/src/components/Loading.vue create mode 100644 frontend/src/components/courses/CourseForm.vue delete mode 100644 frontend/src/components/help/HelpBaseLayout.vue delete mode 100644 frontend/src/components/help/HelpHeader.vue rename frontend/src/components/{teachers_assistants => instructors}/TeacherAssistantCard.vue (83%) rename frontend/src/components/{teachers_assistants => instructors}/TeacherAssistantList.vue (100%) rename frontend/src/components/{teachers_assistants => instructors}/TeacherAssistantSearch.vue (95%) rename frontend/src/components/{teachers_assistants => instructors}/TeacherAssistantUpdateButton.vue (100%) rename frontend/src/components/{teachers_assistants => instructors}/buttons/CourseRoleAddButton.vue (98%) rename frontend/src/components/{teachers_assistants => instructors}/buttons/LeaveCourseButton.vue (100%) delete mode 100644 frontend/src/components/layout/Footer.vue create mode 100644 frontend/src/components/notifications/NotificationCard.vue create mode 100644 frontend/src/components/notifications/NotificationsOverlay.vue create mode 100644 frontend/src/components/notifications/NotificationsScrollPanel.vue rename frontend/src/components/projects/{DownloadCSVButton.vue => ProjectDownloadGradesButton.vue} (63%) rename frontend/src/components/projects/{ExtraChecksUpload.vue => ProjectExtraChecksEditor.vue} (61%) create mode 100644 frontend/src/components/projects/ProjectForm.vue create mode 100644 frontend/src/components/projects/ProjectStructureEditor.vue rename frontend/src/components/projects/{ChooseGroupCard.vue => groups/GroupChooseCard.vue} (72%) rename frontend/src/components/projects/{JoinedGroupCard.vue => groups/GroupJoinedCard.vue} (75%) create mode 100644 frontend/src/components/projects/groups/GroupsManageTable.vue rename frontend/src/components/{projects => submissions}/ProjectMeter.vue (53%) create mode 100644 frontend/src/components/submissions/SubmissionCard.vue create mode 100644 frontend/src/composables/services/feedback.service.ts create mode 100644 frontend/src/composables/services/notification.service.ts create mode 100644 frontend/src/store/notification.store.ts create mode 100644 frontend/src/test/e2e/course.cy.ts create mode 100644 frontend/src/types/ApiResponse.ts create mode 100644 frontend/src/types/Feedback.ts create mode 100644 frontend/src/types/Notification.ts create mode 100644 frontend/src/types/submission/ErrorMessageEnum.ts delete mode 100644 frontend/src/views/admin/AdminView.vue delete mode 100644 frontend/src/views/help/HelpDashboard.vue rename frontend/src/{components => views}/layout/Body.vue (65%) create mode 100644 frontend/src/views/layout/Footer.vue rename frontend/src/{components => views}/layout/Title.vue (100%) rename frontend/src/{components => views}/layout/admin/AdminHeader.vue (98%) rename frontend/src/{components => views}/layout/admin/AdminLayout.vue (70%) rename frontend/src/{components => views}/layout/admin/AdminSidebar.vue (96%) rename frontend/src/{components => views}/layout/base/BaseHeader.vue (83%) rename frontend/src/{components => views}/layout/base/BaseLayout.vue (69%) create mode 100644 frontend/src/views/submissions/SubmissionsView.vue diff --git a/.gitignore b/.gitignore index 02a2ba98..d281d6eb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ data/nginx/ssl/* data/postres* data/redis/* +docs/.vitepress/cache/* + backend/data/production/* backend/staticfiles/* @@ -20,6 +22,3 @@ frontend/cypress/videos/* !data/nginx/nginx.dev.conf !data/nginx/nginx.test.conf !data/nginx/nginx.prod.conf - -docs/.vitepress/dist -docs/.vitepress/cache \ No newline at end of file diff --git a/backend/.tool-versions b/backend/.tool-versions new file mode 100644 index 00000000..c10ee4eb --- /dev/null +++ b/backend/.tool-versions @@ -0,0 +1 @@ +python 3.11.4 diff --git a/backend/api/fixtures/realistic/realistic.yaml b/backend/api/fixtures/realistic/realistic.yaml index bf2f4f15..f7664d29 100644 --- a/backend/api/fixtures/realistic/realistic.yaml +++ b/backend/api/fixtures/realistic/realistic.yaml @@ -33,7 +33,7 @@ visible: true archived: false locked_groups: false - start_date: 2024-12-12T00:00:00Z + start_date: 2023-12-12T00:00:00Z deadline: 2025-01-01T00:00:00Z max_score: 100 score_visible: false @@ -42,8 +42,8 @@ - model: api.project pk: 1 fields: - name: Lean Java - description: This project will teach you the basics of Object Oriented Programming by using Java. + name: Learn Java + description: This project will teach you the basics of Object Oriented Programming using Java. visible: true archived: false locked_groups: false @@ -56,8 +56,8 @@ - model: api.project pk: 2 fields: - name: Lean Javascript - description: This project will show you would you should avoid javascript at all cost. + name: Learn JavaScript + description: This project will show you should avoid JavaScript at all cost. visible: true archived: false locked_groups: true @@ -134,9 +134,18 @@ project: 0 obligated_extensions: - 0 - blocked_extensions: [] + blocked_extensions: + - 1 - model: api.structurecheck pk: 1 + fields: + path: "verslag/" + project: 0 + obligated_extensions: + - 3 + blocked_extensions: [] +- model: api.structurecheck + pk: 2 fields: path: src/ project: 1 @@ -145,7 +154,7 @@ blocked_extensions: - 2 - model: api.structurecheck - pk: 2 + pk: 3 fields: path: verslag/ project: 1 @@ -153,7 +162,7 @@ - 3 blocked_extensions: [] - model: api.structurecheck - pk: 3 + pk: 4 fields: path: "" project: 3 @@ -324,6 +333,54 @@ submission_time: 2024-05-11 12:08:21.147551+00:00 is_valid: true zip: fixtures/realistic/projects/0/0/submissions/1/submission_2/submission.zip +- model: api.submission + pk: 5 + fields: + group: 1 + submission_number: 5 + submission_time: 2024-05-12 12:08:21.147551+00:00 + is_valid: true + zip: fixtures/realistic/projects/0/0/submissions/1/submission_2/submission.zip +- model: api.submission + pk: 6 + fields: + group: 1 + submission_number: 6 + submission_time: 2024-05-13 12:08:21.147551+00:00 + is_valid: true + zip: fixtures/realistic/projects/0/0/submissions/1/submission_2/submission.zip +- model: api.submission + pk: 7 + fields: + group: 1 + submission_number: 7 + submission_time: 2024-05-14 12:08:21.147551+00:00 + is_valid: true + zip: fixtures/realistic/projects/0/0/submissions/1/submission_2/submission.zip +- model: api.submission + pk: 8 + fields: + group: 1 + submission_number: 8 + submission_time: 2024-05-15 12:08:21.147551+00:00 + is_valid: true + zip: fixtures/realistic/projects/0/0/submissions/1/submission_2/submission.zip +- model: api.submission + pk: 9 + fields: + group: 1 + submission_number: 9 + submission_time: 2024-05-16 12:08:21.147551+00:00 + is_valid: true + zip: fixtures/realistic/projects/0/0/submissions/1/submission_2/submission.zip +- model: api.submission + pk: 10 + fields: + group: 1 + submission_number: 10 + submission_time: 2024-05-17 12:08:21.147551+00:00 + is_valid: true + zip: fixtures/realistic/projects/0/0/submissions/1/submission_2/submission.zip # MARK: Check Result - model: api.checkresult @@ -434,6 +491,168 @@ submission: 4 result: SUCCESS error_message: null +- model: api.checkresult + pk: 13 + fields: + polymorphic_ctype: + - api + - structurecheckresult + submission: 5 + result: FAILED + error_message: BLOCKED_EXTENSION +- model: api.checkresult + pk: 14 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 5 + result: FAILED + error_message: FAILED_STRUCTURE_CHECK +- model: api.checkresult + pk: 15 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 5 + result: FAILED + error_message: FAILED_STRUCTURE_CHECK +- model: api.checkresult + pk: 16 + fields: + polymorphic_ctype: + - api + - structurecheckresult + submission: 6 + result: FAILED + error_message: OBLIGATED_EXTENSION_NOT_FOUND +- model: api.checkresult + pk: 17 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 6 + result: FAILED + error_message: FAILED_STRUCTURE_CHECK +- model: api.checkresult + pk: 18 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 6 + result: FAILED + error_message: FAILED_STRUCTURE_CHECK +- model: api.checkresult + pk: 19 + fields: + polymorphic_ctype: + - api + - structurecheckresult + submission: 7 + result: FAILED + error_message: FILE_DIR_NOT_FOUND +- model: api.checkresult + pk: 20 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 7 + result: FAILED + error_message: FAILED_STRUCTURE_CHECK +- model: api.checkresult + pk: 21 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 7 + result: FAILED + error_message: FAILED_STRUCTURE_CHECK +- model: api.checkresult + pk: 22 + fields: + polymorphic_ctype: + - api + - structurecheckresult + submission: 8 + result: SUCCESS + error_message: null +- model: api.checkresult + pk: 23 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 8 + result: FAILED + error_message: DOCKER_IMAGE_ERROR +- model: api.checkresult + pk: 24 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 8 + result: FAILED + error_message: TIME_LIMIT +- model: api.checkresult + pk: 25 + fields: + polymorphic_ctype: + - api + - structurecheckresult + submission: 9 + result: SUCCESS + error_message: null +- model: api.checkresult + pk: 26 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 9 + result: FAILED + error_message: MEMORY_LIMIT +- model: api.checkresult + pk: 27 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 9 + result: FAILED + error_message: CHECK_ERROR +- model: api.checkresult + pk: 28 + fields: + polymorphic_ctype: + - api + - structurecheckresult + submission: 10 + result: SUCCESS + error_message: null +- model: api.checkresult + pk: 29 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 10 + result: FAILED + error_message: RUNTIME_ERROR +- model: api.checkresult + pk: 30 + fields: + polymorphic_ctype: + - api + - extracheckresult + submission: 10 + result: FAILED + error_message: UNKNOWN # MARK: Strucure Check results - model: api.structurecheckresult @@ -452,6 +671,30 @@ pk: 10 fields: structure_check: 0 +- model: api.structurecheckresult + pk: 13 + fields: + structure_check: 0 +- model: api.structurecheckresult + pk: 16 + fields: + structure_check: 0 +- model: api.structurecheckresult + pk: 19 + fields: + structure_check: 0 +- model: api.structurecheckresult + pk: 22 + fields: + structure_check: 0 +- model: api.structurecheckresult + pk: 25 + fields: + structure_check: 0 +- model: api.structurecheckresult + pk: 28 + fields: + structure_check: 0 # MARK: Extra Check Results - model: api.extracheckresult @@ -502,6 +745,78 @@ extra_check: 1 log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_1.txt artifact: "" +- model: api.extracheckresult + pk: 14 + fields: + extra_check: 0 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_0.txt + artifact: "" +- model: api.extracheckresult + pk: 15 + fields: + extra_check: 1 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_1.txt + artifact: "" +- model: api.extracheckresult + pk: 17 + fields: + extra_check: 0 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_0.txt + artifact: "" +- model: api.extracheckresult + pk: 18 + fields: + extra_check: 1 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_1.txt + artifact: "" +- model: api.extracheckresult + pk: 20 + fields: + extra_check: 0 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_0.txt + artifact: "" +- model: api.extracheckresult + pk: 21 + fields: + extra_check: 1 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_1.txt + artifact: "" +- model: api.extracheckresult + pk: 23 + fields: + extra_check: 0 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_0.txt + artifact: "" +- model: api.extracheckresult + pk: 24 + fields: + extra_check: 1 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_1.txt + artifact: "" +- model: api.extracheckresult + pk: 26 + fields: + extra_check: 0 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_0.txt + artifact: "" +- model: api.extracheckresult + pk: 27 + fields: + extra_check: 1 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_1.txt + artifact: "" +- model: api.extracheckresult + pk: 29 + fields: + extra_check: 0 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_0.txt + artifact: "" +- model: api.extracheckresult + pk: 30 + fields: + extra_check: 1 + log_file: fixtures/realistic/projects/0/0/submissions/1/submission_2/logs/log_extra_check_1.txt + artifact: "" # MARK: Teachers - model: api.teacher diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po index dbf3b355..c481af8c 100755 --- a/backend/api/locale/en/LC_MESSAGES/django.po +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-05-15 19:49+0200\n" +"POT-Creation-Date: 2024-05-20 12:24+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -149,6 +149,14 @@ msgid "docker.errors.no_staff" msgstr "User is not allowed to assign othher owners than himself to the image." #: serializers/docker_serializer.py:31 +#: serializers/course_serializer.py:116 +msgid "courses.error.invitation_link" +msgstr "The invitation link is not unique, please try again." + +msgid "feedback.error.no_teacher" +msgstr "The user is no teacher." + +#: serializers/docker_serializer.py:19 msgid "docker.errors.custom" msgstr "User is not allowed to create public images" @@ -180,39 +188,43 @@ msgstr "The student is already in the group." msgid "group.errors.not_present" msgstr "The student is currently not in the group." -#: serializers/project_serializer.py:22 +#: serializers/project_serializer.py:23 msgid "project.errors.invalid_instance" msgstr "Error while parsing the provided zip." -#: serializers/project_serializer.py:81 +#: serializers/project_serializer.py:122 msgid "project.errors.context" msgstr "The project is not supplied in the context." -#: serializers/project_serializer.py:86 +#: serializers/project_serializer.py:127 msgid "project.errors.start_date_in_past" msgstr "The start date of the project lies in the past." -#: serializers/project_serializer.py:100 +#: serializers/project_serializer.py:141 msgid "project.errors.deadline_before_start_date" msgstr "The deadline of the project lies before the start date of the project." -#: serializers/project_serializer.py:142 +#: serializers/project_serializer.py:183 msgid "project.errors.zip_structure" msgstr "Error while parsing the provided zip." -#: serializers/submission_serializer.py:96 tests/test_submission.py:275 +#: serializers/submission_serializer.py:98 +msgid "project.error.submissions.project_not_started" +msgstr "The project hasn't started yet." + +#: serializers/submission_serializer.py:102 tests/test_submission.py:275 msgid "project.error.submissions.past_project" msgstr "The deadline of the project has already passed." -#: serializers/submission_serializer.py:99 tests/test_submission.py:346 +#: serializers/submission_serializer.py:105 tests/test_submission.py:346 msgid "project.error.submissions.non_visible_project" msgstr "The project is currently in a non-visible state." -#: serializers/submission_serializer.py:102 tests/test_submission.py:376 +#: serializers/submission_serializer.py:108 tests/test_submission.py:376 msgid "project.error.submissions.archived_project" msgstr "The project is archived." -#: serializers/submission_serializer.py:105 +#: serializers/submission_serializer.py:111 msgid "project.error.submissions.no_files" msgstr "The submission is empty." @@ -228,39 +240,39 @@ msgstr "The teacher was successfully added." msgid "teachers.success.destroy" msgstr "The teacher was successfully destroyed." -#: views/course_view.py:137 +#: views/course_view.py:136 msgid "courses.success.assistants.add" msgstr "The assistant was successfully added to the course." -#: views/course_view.py:164 +#: views/course_view.py:163 msgid "courses.success.assistants.remove" msgstr "The assistant was successfully removed from the course." -#: views/course_view.py:226 +#: views/course_view.py:225 msgid "courses.success.students.add" msgstr "The student was successfully added to the course." -#: views/course_view.py:247 +#: views/course_view.py:246 msgid "courses.success.students.remove" msgstr "The student was successfully removed from the course." -#: views/course_view.py:292 +#: views/course_view.py:291 msgid "courses.success.teachers.add" msgstr "The teacher was successfully added to the course." -#: views/course_view.py:316 +#: views/course_view.py:315 msgid "courses.success.teachers.remove" msgstr "The teacher was successfully removed from the course." -#: views/group_view.py:74 +#: views/group_view.py:73 msgid "group.success.students.add" msgstr "The student was successfully added to the group." -#: views/group_view.py:94 +#: views/group_view.py:93 msgid "group.success.students.remove" msgstr "The student was successfully removed from the group." -#: views/group_view.py:113 +#: views/group_view.py:112 msgid "group.success.submissions.add" msgstr "The submission was successfully added to the group." @@ -288,6 +300,6 @@ msgstr "No zip file available." msgid "extra_check_result.download.log" msgstr "No log file available." -#: views/submission_view.py:60 +#: views/submission_view.py:59 msgid "extra_check_result.download.artifact" msgstr "No artifact available." diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po index 7acb1b3d..4e39a9b6 100755 --- a/backend/api/locale/nl/LC_MESSAGES/django.po +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-05-15 19:49+0200\n" +"POT-Creation-Date: 2024-05-20 12:28+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -152,68 +152,72 @@ msgstr "Gebruiker is alleen toegelaten om zichzelf als eigenaar op te geven" msgid "docker.errors.custom" msgstr "Gebruiker is niet toegelaten om publieke afbeeldingen te maken" -#: serializers/group_serializer.py:56 +#: serializers/group_serializer.py:57 msgid "group.errors.score_exceeds_max" msgstr "De score van de groep is groter dan de maximum score." -#: serializers/group_serializer.py:66 serializers/group_serializer.py:96 +#: serializers/group_serializer.py:67 serializers/group_serializer.py:97 msgid "group.error.context" msgstr "De groep is niet meegegeven als context waar dat nodig is." -#: serializers/group_serializer.py:74 serializers/group_serializer.py:108 +#: serializers/group_serializer.py:75 serializers/group_serializer.py:113 msgid "group.errors.locked" msgstr "De groep is momenteel vergrendeld." -#: serializers/group_serializer.py:78 +#: serializers/group_serializer.py:79 msgid "group.errors.full" msgstr "De groep is al vol." -#: serializers/group_serializer.py:82 +#: serializers/group_serializer.py:83 msgid "group.errors.not_in_course" msgstr "" "De student bevindt zich niet in de opleiding waartoe het project hoort." -#: serializers/group_serializer.py:86 +#: serializers/group_serializer.py:87 msgid "group.errors.already_in_group" msgstr "De student bevindt zich al in de groep." -#: serializers/group_serializer.py:104 +#: serializers/group_serializer.py:105 +msgid "group.errors.size_one" +msgstr "Het is niet mogelijk om een group met grootte 1 te verlaten." + +#: serializers/group_serializer.py:109 msgid "group.errors.not_present" msgstr "De student bevindt zich niet in de groep." -#: serializers/project_serializer.py:22 +#: serializers/project_serializer.py:23 msgid "project.errors.invalid_instance" msgstr "Error tijdens de zip te overlopen." -#: serializers/project_serializer.py:81 +#: serializers/project_serializer.py:122 msgid "project.errors.context" msgstr "Het project is niet meegegeven als context waar dat nodig is." -#: serializers/project_serializer.py:86 +#: serializers/project_serializer.py:127 msgid "project.errors.start_date_in_past" msgstr "De startdatum van het project ligt in het verleden." -#: serializers/project_serializer.py:100 +#: serializers/project_serializer.py:141 msgid "project.errors.deadline_before_start_date" msgstr "De uiterste inleverdatum voor het project ligt voor de startdatum." -#: serializers/project_serializer.py:142 +#: serializers/project_serializer.py:183 msgid "project.errors.zip_structure" msgstr "Error tijdens de zip te overlopen." -#: serializers/submission_serializer.py:96 tests/test_submission.py:275 +#: serializers/submission_serializer.py:99 tests/test_submission.py:275 msgid "project.error.submissions.past_project" msgstr "De uiterste inleverdatum voor het project is gepasseerd." -#: serializers/submission_serializer.py:99 tests/test_submission.py:346 +#: serializers/submission_serializer.py:102 tests/test_submission.py:346 msgid "project.error.submissions.non_visible_project" msgstr "Het project is niet zichtbaar." -#: serializers/submission_serializer.py:102 tests/test_submission.py:376 +#: serializers/submission_serializer.py:105 tests/test_submission.py:376 msgid "project.error.submissions.archived_project" msgstr "Het project is gearchiveerd." -#: serializers/submission_serializer.py:105 +#: serializers/submission_serializer.py:108 msgid "project.error.submissions.no_files" msgstr "De indiening is leeg" @@ -229,39 +233,39 @@ msgstr "De lesgever is successvol toegevoegd." msgid "teachers.success.destroy" msgstr "De lesgever is succesvol verwijderd." -#: views/course_view.py:137 +#: views/course_view.py:136 msgid "courses.success.assistants.add" msgstr "De assistent is succesvol toegevoegd aan de opleiding." -#: views/course_view.py:164 +#: views/course_view.py:163 msgid "courses.success.assistants.remove" msgstr "De assistent is succesvol verwijderd uit de opleiding." -#: views/course_view.py:226 +#: views/course_view.py:225 msgid "courses.success.students.add" msgstr "De student is succesvol toegevoegd aan de opleiding." -#: views/course_view.py:247 +#: views/course_view.py:246 msgid "courses.success.students.remove" msgstr "De student is succesvol verwijderd uit de opleiding." -#: views/course_view.py:292 +#: views/course_view.py:291 msgid "courses.success.teachers.add" msgstr "De lesgever is succesvol toegevoegd aan de opleiding." -#: views/course_view.py:316 +#: views/course_view.py:315 msgid "courses.success.teachers.remove" msgstr "De lesgever is succesvol verwijderd uit de opleiding." -#: views/group_view.py:74 +#: views/group_view.py:73 msgid "group.success.students.add" msgstr "De student is succesvol toegevoegd aan de groep." -#: views/group_view.py:94 +#: views/group_view.py:93 msgid "group.success.students.remove" msgstr "De student is succesvol verwijderd uit de groep." -#: views/group_view.py:113 +#: views/group_view.py:112 msgid "group.success.submissions.add" msgstr "De indiening is succesvol toegevoegd aan de groep." @@ -289,7 +293,7 @@ msgstr "Geen zip bestand beschikbaar." msgid "extra_check_result.download.log" msgstr "Geen log bestand beschikbaar." -#: views/submission_view.py:60 +#: views/submission_view.py:59 #, fuzzy #| msgid "extra_check_result.download.log" msgid "extra_check_result.download.artifact" diff --git a/backend/api/logic/parse_zip_files.py b/backend/api/logic/parse_zip_files.py index 7858a585..cc21f531 100644 --- a/backend/api/logic/parse_zip_files.py +++ b/backend/api/logic/parse_zip_files.py @@ -12,12 +12,13 @@ def parse_zip(project: Project, zip_file: InMemoryUploadedFile) -> bool: zip_file.seek(0) - with zipfile.ZipFile(zip_file, 'r') as zip: - files = zip.namelist() + with zipfile.ZipFile(zip_file, 'r') as zip_file: + files = zip_file.namelist() directories = [file for file in files if file.endswith('/')] # Check if all directories start the same common_prefix = os.path.commonprefix(directories) + if '/' in common_prefix: prefixes = common_prefix.split('/') if common_prefix[-1] != '/': @@ -31,6 +32,7 @@ def parse_zip(project: Project, zip_file: InMemoryUploadedFile) -> bool: # Add potential top level files top_level_files = [file for file in files if '/' not in file] + if top_level_files: create_check(project, '', files) diff --git a/backend/api/management/commands/teacher_join_course.py b/backend/api/management/commands/teacher_join_course.py deleted file mode 100644 index a5edf26c..00000000 --- a/backend/api/management/commands/teacher_join_course.py +++ /dev/null @@ -1,35 +0,0 @@ -from django.core.management.base import BaseCommand -from api.models.teacher import Teacher -from api.models.course import Course - - -class Command(BaseCommand): - - help = 'make a teacher join a course' - - def add_arguments(self, parser): - parser.add_argument('username', type=str, help='The username of the teacher to join the course') - parser.add_argument('course_id', type=str, help='The id of the course you want to join') - - def handle(self, *args, **options): - username = options['username'] - course_id = options['course_id'] - teacher = Teacher.objects.filter(username=username) - - if teacher.count() == 0: - self.stdout.write(self.style.ERROR('Teacher not found, first log in !')) - return - - teacher = teacher.get() - - course = Course.objects.filter(id=course_id) - - if course.count() == 0: - self.stdout.write(self.style.ERROR('Course not found, first create it !')) - return - - course = course.get() - - teacher.courses.add(course_id) - - self.stdout.write(self.style.SUCCESS('Successfully add teacher to course!')) diff --git a/backend/api/migrations/0019_feedback.py b/backend/api/migrations/0019_feedback.py new file mode 100644 index 00000000..7c3f9d7a --- /dev/null +++ b/backend/api/migrations/0019_feedback.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.4 on 2024-05-02 15:15 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0018_course_invitation_link_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Feedback', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message', models.TextField()), + ('creation_date', models.DateTimeField(auto_now_add=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedback_messages', to=settings.AUTH_USER_MODEL)), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedback', to='api.submission')), + ], + ), + ] diff --git a/backend/api/migrations/0024_merge_20240507_1230.py b/backend/api/migrations/0024_merge_20240507_1230.py new file mode 100644 index 00000000..aef0e84b --- /dev/null +++ b/backend/api/migrations/0024_merge_20240507_1230.py @@ -0,0 +1,14 @@ +# Generated by Django 5.0.4 on 2024-05-07 12:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0019_feedback'), + ('api', '0023_submission_zip_alter_checkresult_error_message_and_more'), + ] + + operations = [ + ] diff --git a/backend/api/migrations/0026_merge_20240518_1058.py b/backend/api/migrations/0026_merge_20240518_1058.py new file mode 100644 index 00000000..e148a5e2 --- /dev/null +++ b/backend/api/migrations/0026_merge_20240518_1058.py @@ -0,0 +1,14 @@ +# Generated by Django 5.0.4 on 2024-05-18 10:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0024_merge_20240507_1230'), + ('api', '0025_extracheckresult_artifact'), + ] + + operations = [ + ] diff --git a/backend/api/models/feedback.py b/backend/api/models/feedback.py new file mode 100644 index 00000000..d765b8ac --- /dev/null +++ b/backend/api/models/feedback.py @@ -0,0 +1,38 @@ +from django.db import models +from api.models.submission import Submission +from authentication.models import User + + +class Feedback(models.Model): + """Model that represents a feedback message.""" + + # ID should be generated automatically + + # Feedback message + message = models.TextField(null=False) + + # Feedback message author + author = models.ForeignKey( + User, + # If the author is deleted, the feedback message should be deleted as well + on_delete=models.CASCADE, + related_name="feedback_messages", + blank=False, + null=False, + ) + + # Feedback message creation date + creation_date = models.DateTimeField( + # The default value is the current date and time + auto_now_add=True, + blank=False, + null=False + ) + + submission = models.ForeignKey( + Submission, + on_delete=models.CASCADE, + related_name="feedback", + blank=False, + null=False + ) diff --git a/backend/api/models/project.py b/backend/api/models/project.py index 2a390b70..d1d250c6 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -76,6 +76,11 @@ def deadline_passed(self): now = timezone.now() return now > self.deadline + def has_started(self): + """Returns True if the project has started.""" + now = timezone.now() + return now >= self.start_date + def is_archived(self): """Returns True if a project is archived.""" return self.archived @@ -108,6 +113,6 @@ def increase_deadline(self, days): self.save() if TYPE_CHECKING: - groups: RelatedManager['Group'] - structure_checks: RelatedManager['StructureCheck'] - extra_checks: RelatedManager['ExtraCheck'] + groups: RelatedManager[Group] + structure_checks: RelatedManager[StructureCheck] + extra_checks: RelatedManager[ExtraCheck] diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py index 71e3d04c..7c24fa2c 100644 --- a/backend/api/permissions/course_permissions.py +++ b/backend/api/permissions/course_permissions.py @@ -1,3 +1,8 @@ +from typing import cast + +from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.auth.models import AbstractUser + from api.models.course import Course from api.permissions.role_permissions import (is_assistant, is_student, is_teacher) @@ -12,7 +17,7 @@ class CoursePermission(BasePermission): def has_permission(self, request: Request, view: ViewSet) -> bool: """Check if user has permission to view a general course endpoint.""" - user: User = request.user + user: AbstractBaseUser = request.user # Logged-in users can fetch course information. if request.method in SAFE_METHODS: @@ -23,7 +28,7 @@ def has_permission(self, request: Request, view: ViewSet) -> bool: def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: """Check if user has permission to view a detailed course endpoint""" - user = request.user + user: User = cast(User, request.user) # Logged-in users can fetch course details. if request.method in SAFE_METHODS: diff --git a/backend/api/permissions/feedback_permissions.py b/backend/api/permissions/feedback_permissions.py new file mode 100644 index 00000000..8bcdcfa9 --- /dev/null +++ b/backend/api/permissions/feedback_permissions.py @@ -0,0 +1,17 @@ +from rest_framework import permissions + +from api.permissions.role_permissions import is_teacher + + +class IsAdminOrTeacherForPatch(permissions.BasePermission): + """ + Custom permission to only allow admins to access objects in general, + but teachers can only make PATCH requests. + """ + + def has_permission(self, request, view): + if request.user.is_authenticated and request.user.is_staff: + return True + elif request.method == 'PATCH' and is_teacher(request.user): + return True + return False diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index 00f15f6a..f2014ac6 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -1,4 +1,10 @@ +from typing import cast + +from api.models.assistant import Assistant from api.models.group import Group +from api.models.project import Project +from api.models.student import Student +from api.models.teacher import Teacher from api.permissions.role_permissions import (is_assistant, is_student, is_teacher) from authentication.models import User @@ -62,34 +68,37 @@ class GroupSubmissionPermission(BasePermission): """Permission class for submission related group endpoints""" def has_permission(self, request: Request, view: APIView) -> bool: - user: User = request.user + user = cast(User, request.user) group_id = view.kwargs.get('pk') - group: Group | None = Group.objects.get(id=group_id) if group_id else None + + if group_id is None: + return False + + group: Group | None = Group.objects.get(id=group_id) if group is None: return True - # Teachers and assistants of that course can view all submissions - if is_teacher(user): - return group.project.course.teachers.filter(id=user.teacher.id).exists() - - if is_assistant(user): - return group.project.course.assistants.filter(id=user.assistant.id).exists() + # Get the individual permissions. + teacher_permission = group.project.course.teachers.filter(id=user.id).exists() + assistant_permission = group.project.course.assistants.filter(id=user.id).exists() + student_permission = group.students.filter(id=user.id).exists() - return is_student(user) and group.students.filter(id=user.student.id).exists() + return teacher_permission or assistant_permission or student_permission - def had_object_permission(self, request: Request, view: ViewSet, group) -> bool: - user: User = request.user + def had_object_permission(self, request: Request, view: ViewSet, group: Group) -> bool: + """Check if user has permission to view a detailed group submission endpoint""" + user = request.user course = group.project.course - teacher_or_assitant = is_teacher(user) and user.teacher.courses.filter( - id=course.id).exists() or is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() - if request.method in SAFE_METHODS: - # Users related to the group can view the submissions of the group - return teacher_or_assitant or (is_student(user) and user.student.groups.filter(id=group.id).exists()) + # Check if the user is a teacher that has the course linked to the project. + teacher = Teacher.objects.filter(id=user.id).first() + assistant = Assistant.objects.filter(id=user.id).first() + student = Student.objects.filter(id=user.id).first() - # Student can only add submissions to their own group - if is_student(user) and request.data.get("student") == user.id and view.action == "create": # type: ignore - return user.student.courses.filter(id=course.id).exists() + # Get the individual permission clauses. + teacher_permission = teacher is not None and teacher.courses.filter(id=course.id).exists() + assistant_permission = assistant is not None and assistant.courses.filter(id=course.id).exists() + student_permission = student is not None and student.groups.filter(id=group.id).exists() - return teacher_or_assitant + return teacher_permission or assistant_permission or student_permission diff --git a/backend/api/permissions/project_permissions.py b/backend/api/permissions/project_permissions.py index a13b4946..70510469 100644 --- a/backend/api/permissions/project_permissions.py +++ b/backend/api/permissions/project_permissions.py @@ -1,3 +1,6 @@ +from api.models.assistant import Assistant +from api.models.student import Student +from api.models.teacher import Teacher from api.permissions.role_permissions import (is_assistant, is_student, is_teacher) from authentication.models import User @@ -11,38 +14,46 @@ class ProjectPermission(BasePermission): def has_permission(self, request: Request, view: ViewSet) -> bool: """Check if user has permission to view a general project endpoint.""" - user: User = request.user - - # We only allow teachers and assistants to create new projects. - return is_teacher(user) or is_assistant(user) + return is_teacher(request.user) or is_assistant(request.user) or request.method in SAFE_METHODS def has_object_permission(self, request: Request, view: ViewSet, project) -> bool: """Check if user has permission to view a detailed project endpoint""" - user: User = request.user - course = project.course - teacher_or_assistant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + user = request.user + + # Check if the user is a teacher that has the course linked to the project. + teacher = Teacher.objects.filter(id=user.id).first() + assistant = Assistant.objects.filter(id=user.id).first() + student = Student.objects.filter(id=user.id).first() + + # Get the individual permission clauses. + teacher_permission = teacher is not None and teacher.courses.filter(id=project.course.id).exists() + assistant_permission = assistant is not None and assistant.courses.filter(id=project.course.id).exists() + student_permission = student is not None and student.courses.filter(id=project.course.id).exists() if request.method in SAFE_METHODS: - # Users that are linked to the course can view the project. - return teacher_or_assistant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) + return teacher_permission or assistant_permission or student_permission - # We only allow teachers and assistants to modify specified projects. - return teacher_or_assistant + return teacher_permission or assistant_permission class ProjectGroupPermission(BasePermission): """Permission class for project related group endpoints""" + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general project group endpoint.""" + return is_teacher(request.user) or is_assistant(request.user) or request.method in SAFE_METHODS def has_object_permission(self, request: Request, view: ViewSet, project) -> bool: - user: User = request.user - course = project.course - teacher_or_assistant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + """Check if user has permission to view a detailed project group endpoint""" + user = request.user - if request.method in SAFE_METHODS: - # Users that are linked to the course can view the group. - return teacher_or_assistant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) + # Check if the user is a teacher that has the course linked to the project. + teacher = Teacher.objects.filter(id=user.id).first() + assistant = Assistant.objects.filter(id=user.id).first() + student = Student.objects.filter(id=user.id).first() + + # Get the individual permission clauses. + teacher_permission = teacher is not None and teacher.courses.filter(id=project.course.id).exists() + assistant_permission = assistant is not None and assistant.courses.filter(id=project.course.id).exists() + student_permission = student is not None and student.courses.filter(id=project.course.id).exists() - # We only allow teachers and assistants to create new groups. - return teacher_or_assistant + return teacher_permission or assistant_permission or student_permission diff --git a/backend/api/permissions/role_permissions.py b/backend/api/permissions/role_permissions.py index 68cc428e..b0846746 100644 --- a/backend/api/permissions/role_permissions.py +++ b/backend/api/permissions/role_permissions.py @@ -1,3 +1,4 @@ +from django.contrib.auth.base_user import AbstractBaseUser from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.viewsets import ViewSet @@ -7,17 +8,17 @@ from api.models.teacher import Teacher -def is_student(user: User) -> bool: +def is_student(user: AbstractBaseUser) -> bool: """Check whether the user is a student""" return Student.objects.filter(id=user.id, is_active=True).exists() -def is_assistant(user: User) -> bool: +def is_assistant(user: AbstractBaseUser) -> bool: """Check whether the user is an assistant""" return Assistant.objects.filter(id=user.id, is_active=True).exists() -def is_teacher(user: User) -> bool: +def is_teacher(user: AbstractBaseUser) -> bool: """Check whether the user is a teacher""" return Teacher.objects.filter(id=user.id, is_active=True).exists() diff --git a/backend/api/permissions/submission_permissions.py b/backend/api/permissions/submission_permissions.py index 7180a0fa..f69c81ef 100644 --- a/backend/api/permissions/submission_permissions.py +++ b/backend/api/permissions/submission_permissions.py @@ -12,12 +12,15 @@ class SubmissionPermission(BasePermission): def has_permission(self, request: Request, view: APIView) -> bool: - if request.method not in SAFE_METHODS: + submission_id = view.kwargs.get("pk") + + if request.method not in SAFE_METHODS or submission_id is None: return False user: User = cast(User, request.user) - - return user.is_staff or is_teacher(user) or is_assistant(user) + # check if user is in group of submission + group = Submission.objects.get(id=submission_id).group + return user.is_staff or is_teacher(user) or is_assistant(user) or group.students.filter(id=user.id).exists() def has_object_permission(self, request: Request, view: APIView, obj: Submission) -> bool: if request.method not in SAFE_METHODS: diff --git a/backend/api/seeders/seeder.py b/backend/api/seeders/seeder.py index c5991fa6..3c81c82e 100644 --- a/backend/api/seeders/seeder.py +++ b/backend/api/seeders/seeder.py @@ -3,8 +3,12 @@ from time import time from typing import Literal +from api.models.submission import StructureCheckResult +from api.models.submission import ExtraCheckResult + from django.db import connection from django.utils import timezone +from django.contrib.contenttypes.models import ContentType generated_usernames = set() @@ -268,7 +272,7 @@ def seed_projects( visible_prob=80, archived_prob=10, score_visible_prob=30, - locked_groups_prob=30, + locked_groups_prob=0, min_max_score=1, max_max_score=100, min_group_size=1, @@ -435,7 +439,7 @@ def seed_structure_checks(faker, count: int = 1_500): while len(blocked_extensions) < count * 2: project = faker.pyint(min_value=0, max_value=count - 1) extension = choice(extensions)[0] - if [project, extension] not in blocked_extensions: + if ([project, extension] not in blocked_extensions) and ([project, extension] not in obligated_extensions): blocked_extensions.append([project, extension]) cursor.executemany( @@ -536,6 +540,17 @@ def seed_submission_results(faker): results = [] structure_results = [] extra_results = [] + # Get the content type for the StructureCheckResult model + structure_content_type = ContentType.objects.get_for_model(StructureCheckResult) + + # Get the ID of the content type + structure_content_type_id = structure_content_type.id + + # Get the content type for the ExtraCheckResult model + extra_content_type = ContentType.objects.get_for_model(ExtraCheckResult) + + # Get the ID of the content type + extra_content_type_id = extra_content_type.id for submission in submissions: project = next(filter(lambda group: group[0] == submission[1], groups))[1] @@ -549,6 +564,7 @@ def seed_submission_results(faker): id, result, choice(error_structure) if result == "FAILED" else None, + structure_content_type_id, submission[0], ]) @@ -564,6 +580,7 @@ def seed_submission_results(faker): id, result, choice(error_extra) if result == "FAILED" else None, + extra_content_type_id, submission[0], ]) @@ -574,7 +591,9 @@ def seed_submission_results(faker): ]) cursor.executemany( - "INSERT INTO api_checkresult(id, result, error_message, submission_id) VALUES (?, ?, ?, ?)", + "INSERT INTO api_checkresult(" + "id, result, error_message, polymorphic_ctype_id, submission_id" + ") VALUES (?, ?, ?, ?, ?)", results ) cursor.executemany( diff --git a/backend/api/serializers/checks_serializer.py b/backend/api/serializers/checks_serializer.py index a6c06922..54454815 100644 --- a/backend/api/serializers/checks_serializer.py +++ b/backend/api/serializers/checks_serializer.py @@ -3,104 +3,115 @@ from api.models.project import Project from django.utils.translation import gettext as _ from rest_framework import serializers -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import ValidationError, NotFound class FileExtensionSerializer(serializers.ModelSerializer): + extension = serializers.CharField( + required=True, + max_length=10 + ) + class Meta: model = FileExtension - fields = ["extension"] - - -class FileExtensionHyperLinkedRelatedField(serializers.HyperlinkedRelatedField): - view_name = "file-extensions-detail" - queryset = FileExtension.objects.all() + fields = ["id", "extension"] - def to_internal_value(self, data): - try: - return self.queryset.get(pk=data) - except FileExtension.DoesNotExist: - return self.fail("no_match") - -# TODO: Support partial updates class StructureCheckSerializer(serializers.ModelSerializer): - project = serializers.HyperlinkedRelatedField( - view_name="project-detail", - read_only=True + read_only=True, + view_name="project-detail" ) - obligated_extensions = FileExtensionSerializer(many=True, required=False, default=[]) - - blocked_extensions = FileExtensionSerializer(many=True, required=False, default=[]) - - class Meta: - model = StructureCheck - fields = "__all__" - + obligated_extensions = FileExtensionSerializer( + many=True + ) -# TODO: Simplify -class StructureCheckAddSerializer(StructureCheckSerializer): + blocked_extensions = FileExtensionSerializer( + many=True + ) def validate(self, attrs): + """Validate the structure check""" project: Project = self.context["project"] - if project.structure_checks.filter(path=attrs["path"]).count(): + + # The structure check path should not exist already + if project.structure_checks.filter(path=attrs["path"]).exists(): raise ValidationError(_("project.error.structure_checks.already_existing")) - obl_ext = set() - for ext in self.context["obligated"]: - extension, result = FileExtension.objects.get_or_create( - extension=ext - ) - obl_ext.add(extension) - attrs["obligated_extensions"] = obl_ext + # The same extension should not be in both blocked and obligated + blocked = set([ext["extension"] for ext in attrs["blocked_extensions"]]) + obligated = set([ext["extension"] for ext in attrs["obligated_extensions"]]) - block_ext = set() - for ext in self.context["blocked"]: - extension, result = FileExtension.objects.get_or_create( - extension=ext - ) - if extension in obl_ext: - raise ValidationError(_("project.error.structure_checks.extension_blocked_and_obligated")) - block_ext.add(extension) - attrs["blocked_extensions"] = block_ext + if blocked.intersection(obligated): + raise ValidationError(_("project.error.structure_checks.extension_blocked_and_obligated")) return attrs + def create(self, validated_data: dict) -> StructureCheck: + """Create a new structure check""" + blocked = validated_data.pop("blocked_extensions") + obligated = validated_data.pop("obligated_extensions") -class DockerImagerHyperLinkedRelatedField(serializers.HyperlinkedRelatedField): - view_name = "docker-image-detail" - queryset = DockerImage.objects.all() + check: StructureCheck = StructureCheck.objects.create( + path=validated_data.pop("path"), + **validated_data + ) - def to_internal_value(self, data): - try: - return self.queryset.get(pk=data) - except DockerImage.DoesNotExist: - return self.fail("no_match") + for ext in obligated: + ext, _ = FileExtension.objects.get_or_create( + extension=ext["extension"] + ) + check.obligated_extensions.add(ext) + # Add blocked extensions + for ext in blocked: + ext, _ = FileExtension.objects.get_or_create( + extension=ext["extension"] + ) + check.blocked_extensions.add(ext) -class ExtraCheckSerializer(serializers.ModelSerializer): + return check + + class Meta: + model = StructureCheck + fields = "__all__" + +class ExtraCheckSerializer(serializers.ModelSerializer): project = serializers.HyperlinkedRelatedField( - view_name="project-detail", - read_only=True + read_only=True, + view_name="project-detail" ) - docker_image = DockerImagerHyperLinkedRelatedField() + docker_image = serializers.HyperlinkedRelatedField( + read_only=True, + view_name="docker-image-detail" + ) class Meta: model = ExtraCheck fields = "__all__" - def validate(self, attrs): + def validate(self, attrs: dict) -> dict: + """Validate the extra check""" data = super().validate(attrs) - # Only check if docker image is present when it is not a partial update - if not self.partial: - if "docker_image" not in data: - raise serializers.ValidationError(_("extra_check.error.docker_image")) + # Check if the docker image is provided + if "docker_image" not in self.initial_data: + raise serializers.ValidationError(_("extra_check.error.docker_image")) + + # Check if the docker image exists + image = DockerImage.objects.get( + id=self.initial_data["docker_image"] + ) + + if image is None: + raise NotFound(_("extra_check.error.docker_image")) + + data["docker_image"] = image + # Check if the time limit and memory limit are in the correct range if "time_limit" in data and not 10 <= data["time_limit"] <= 1000: raise serializers.ValidationError(_("extra_check.error.time_limit")) diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index 5d995446..b2db9097 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -43,11 +43,32 @@ class CourseSerializer(serializers.ModelSerializer): read_only=True ) + faculty_id = serializers.PrimaryKeyRelatedField( + queryset=Faculty.objects.all(), + source="faculty", + write_only=True, + ) + def validate(self, attrs: dict) -> dict: """Extra custom validation for course serializer""" + attrs = super().validate(attrs) + + # Clean the description attrs['description'] = clean(attrs['description']) + return attrs + def create(self, validated_data): + # Create the course + course = super().create(validated_data) + + # Compute the invitation link hash + course.invitation_link = hashlib.sha256(f'{course.id}{course.academic_startyear}'.encode()).hexdigest() + course.invitation_link_expires = timezone.now() + course.save() + + return course + def to_representation(self, instance): data = super().to_representation(instance) @@ -69,6 +90,7 @@ class Meta: "excerpt", "description", "faculty", + "faculty_id", "parent_course", "private_course", "teachers", @@ -78,44 +100,6 @@ class Meta: ] -class CreateCourseSerializer(CourseSerializer): - faculty = serializers.PrimaryKeyRelatedField( - queryset=Faculty.objects.all(), - required=False, - allow_null=True, - ) - - def create(self, validated_data): - faculty = validated_data.pop('faculty', None) - - # Create the course - course = super().create(validated_data) - - # Compute the invitation link hash - course.invitation_link = hashlib.sha256(f'{course.id}{course.academic_startyear}'.encode()).hexdigest() - course.invitation_link_expires = timezone.now() - - # Link the faculty, if specified - if faculty is not None: - course.faculty = faculty - course.save() - - return course - - def update(self, instance, validated_data): - faculty = validated_data.pop('faculty', None) - - # Update the course - course = super().update(instance, validated_data) - - # Link the faculty, if specified - if faculty is not None: - course.faculty = faculty - course.save() - - return course - - class CourseIDSerializer(serializers.Serializer): student_id = serializers.PrimaryKeyRelatedField( queryset=Course.objects.all() diff --git a/backend/api/serializers/feedback_serializer.py b/backend/api/serializers/feedback_serializer.py new file mode 100644 index 00000000..7ba899c1 --- /dev/null +++ b/backend/api/serializers/feedback_serializer.py @@ -0,0 +1,25 @@ +from django.utils.translation import gettext +from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from api.models.feedback import Feedback +from api.permissions.role_permissions import is_teacher +from api.serializers.submission_serializer import SubmissionSerializer +from api.serializers.teacher_serializer import TeacherSerializer + + +class FeedbackSerializer(serializers.ModelSerializer): + + submission = SubmissionSerializer(read_only=True) + author = TeacherSerializer(read_only=True) + + """Serializer for the feedback message""" + def to_internal_value(self, data): + if "user" in self.context: + if not is_teacher(self.context["user"]): + raise ValidationError(gettext("feedback.error.no_teacher")) + return super().to_internal_value(data) + + class Meta: + model = Feedback + fields = "__all__" + read_only_fields = ["author", "submission"] diff --git a/backend/api/serializers/fields/__init__.py b/backend/api/serializers/fields/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/serializers/fields/expandable_hyperlinked_field.py b/backend/api/serializers/fields/expandable_hyperlinked_field.py new file mode 100644 index 00000000..c424e471 --- /dev/null +++ b/backend/api/serializers/fields/expandable_hyperlinked_field.py @@ -0,0 +1,34 @@ +from typing import Type + +from rest_framework import serializers +from rest_framework.request import Request +from rest_framework.serializers import Serializer + + +class ExpandableHyperlinkedIdentityField(serializers.HyperlinkedIdentityField): + """A HyperlinkedIdentityField with nested serializer expanding""" + + def __init__(self, serializer: Type[Serializer], view_name: str = None, **kwargs): + self.serializer = serializer + super().__init__(view_name=view_name, **kwargs) + + def get_url(self, obj: any, view_name: str, request: Request, fm: str): + """Get the URL of the related object""" + return super().get_url(obj, view_name, request, fm) + + def to_representation(self, value): + """Get the representation of the nested instance""" + request: Request = self.context.get('request') + + if request and self.field_name in request.query_params: + try: + instance = getattr(value, self.field_name) + except AttributeError: + instance = value + + return self.serializer(instance, + many=self._kwargs.pop('many'), + context=self.context + ).data + + return super().to_representation(value) diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index 3865f7a2..726a0a80 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -1,18 +1,20 @@ +from api.models.assistant import Assistant from api.models.group import Group from api.models.student import Student -from api.models.assistant import Assistant from api.models.teacher import Teacher -from api.permissions.role_permissions import is_student, is_assistant, is_teacher +from api.permissions.role_permissions import (is_assistant, is_student, + is_teacher) from api.serializers.project_serializer import ProjectSerializer from api.serializers.student_serializer import StudentIDSerializer from django.utils.translation import gettext +from notifications.signals import NotificationType, notification_create from rest_framework import serializers from rest_framework.exceptions import ValidationError class GroupSerializer(serializers.ModelSerializer): - project = ProjectSerializer( - read_only=True, + project = serializers.HyperlinkedIdentityField( + view_name="project-detail" ) students = serializers.HyperlinkedIdentityField( @@ -20,28 +22,32 @@ class GroupSerializer(serializers.ModelSerializer): read_only=True, ) + occupation = serializers.SerializerMethodField() + submissions = serializers.HyperlinkedIdentityField( view_name="group-submissions", read_only=True, ) - class Meta: - model = Group - fields = "__all__" + def get_occupation(self, instance: Group): + """Get the number of students in the group""" + return instance.students.count() - def to_representation(self, instance): + def to_representation(self, instance: Group): + """Convert the group to a JSON representation""" data = super().to_representation(instance) user = self.context["request"].user course_id = instance.project.course.id # If you are not a student, you can always see the score - if is_student(user): + if is_student(user) and not is_teacher(user) and not is_assistant(user) and not user.is_staff: student_in_course = user.student.courses.filter(id=course_id).exists() + # Student can not see the score if they are not part of the course associated with group and # neither an assistant or a teacher, # or it is not visible yet when they are part of the course associated with the group - if not student_in_course and not is_assistant(user) and not is_teacher(user) or \ + if not student_in_course or \ not instance.project.score_visible and student_in_course: data.pop("score") @@ -49,14 +55,20 @@ def to_representation(self, instance): def validate(self, attrs): # Make sure the score of the group is lower or equal to the maximum score - self.instance: Group - group = self.instance + group: Group = self.instance or self.context.get("group") + + if group is None: + raise ValueError("Group is not in context") if "score" in attrs and attrs["score"] > group.project.max_score: raise ValidationError(gettext("group.errors.score_exceeds_max")) return attrs + class Meta: + model = Group + fields = "__all__" + class StudentJoinGroupSerializer(StudentIDSerializer): @@ -69,10 +81,6 @@ def validate(self, attrs): group: Group = self.context["group"] student: Student = attrs["student"] - # Make sure a student can't join if groups are locked - if group.project.is_groups_locked(): - raise ValidationError(gettext("group.errors.locked")) - # Make sure the group is not already full if group.is_full(): raise ValidationError(gettext("group.errors.full")) @@ -99,6 +107,10 @@ def validate(self, attrs): group: Group = self.context["group"] student: Student = attrs["student"] + # Make sure the group size is not 1 + if group.project.group_size == 1: + raise ValidationError(gettext("group.errors.size_one")) + # Make sure the student was in the group if not group.students.filter(id=student.id).exists(): raise ValidationError(gettext("group.errors.not_present")) diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index b4493229..1deda839 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -2,6 +2,7 @@ from api.models.group import Group from api.models.project import Project from api.models.submission import Submission, ExtraCheckResult, StructureCheckResult, StateEnum +from api.models.checks import ExtraCheck, StructureCheck from api.serializers.course_serializer import CourseSerializer from django.core.files.uploadedfile import InMemoryUploadedFile from django.utils import timezone @@ -24,6 +25,7 @@ def to_representation(self, instance: Project): non_empty_groups = Group.objects.filter(project=instance, students__isnull=False).distinct().count() + # groups_submitted groups_submitted_ids = Submission.objects.filter(group__project=instance).values_list('group__id', flat=True) unique_groups = set(groups_submitted_ids) groups_submitted = len(unique_groups) @@ -33,48 +35,63 @@ def to_representation(self, instance: Project): if (groups_submitted > non_empty_groups): non_empty_groups = groups_submitted - passed_structure_checks_submission_ids = StructureCheckResult.objects.filter( - submission__group__project=instance, - submission__is_valid=True, - result=StateEnum.SUCCESS - ).values_list('submission__id', flat=True) + # has_structure_checks + has_structure_checks = instance.structure_checks.count() != 0 - passed_structure_checks_group_ids = Submission.objects.filter( - id__in=passed_structure_checks_submission_ids - ).values_list('group_id', flat=True) + # has_extra checks + has_extra_checks = instance.extra_checks.count() != 0 - unique_groups = set(passed_structure_checks_group_ids) - structure_checks_passed = len(unique_groups) + # extra_checks_passed: only calculate if the project actually contains extra checks + extra_checks_passed = 0 + if has_extra_checks: + passed_extra_checks_submission_ids = ExtraCheckResult.objects.filter( + submission__group__project=instance, + submission__is_valid=True, + result=StateEnum.SUCCESS + ).values_list('submission__id', flat=True) - passed_extra_checks_submission_ids = ExtraCheckResult.objects.filter( - submission__group__project=instance, - submission__is_valid=True, - result=StateEnum.SUCCESS - ).values_list('submission__id', flat=True) + passed_extra_checks_group_ids = Submission.objects.filter( + id__in=passed_extra_checks_submission_ids + ).values_list('group_id', flat=True) - passed_extra_checks_group_ids = Submission.objects.filter( - id__in=passed_extra_checks_submission_ids - ).values_list('group_id', flat=True) + unique_groups = set(passed_extra_checks_group_ids) + extra_checks_passed = len(unique_groups) - unique_groups = set(passed_extra_checks_group_ids) - extra_checks_passed = len(unique_groups) + # structure_checks_passed: only calculate if the project actually contains structure checks + structure_checks_passed = 0 + if has_structure_checks: + passed_structure_checks_submission_ids = StructureCheckResult.objects.filter( + submission__group__project=instance, + submission__is_valid=True, + result=StateEnum.SUCCESS + ).values_list('submission__id', flat=True) - # The total number of passed extra checks combined with the number of passed structure checks - # can never exceed the total number of submissions (the seeder does not account for this restriction) - if (structure_checks_passed + extra_checks_passed > groups_submitted): - extra_checks_passed = groups_submitted - structure_checks_passed + passed_structure_checks_group_ids = Submission.objects.filter( + id__in=passed_structure_checks_submission_ids + ).values_list('group_id', flat=True) + + unique_groups = set(passed_structure_checks_group_ids) + structure_checks_passed = len(unique_groups) + + # We can assume that if the extra checks pass, the struture checks pass as well + if (structure_checks_passed < extra_checks_passed): + structure_checks_passed = extra_checks_passed return { "non_empty_groups": non_empty_groups, "groups_submitted": groups_submitted, + "has_structure_checks": has_structure_checks, + "has_extra_checks": has_extra_checks, "structure_checks_passed": structure_checks_passed, - "extra_checks_passed": extra_checks_passed + "extra_checks_passed": extra_checks_passed, } class Meta: fields = [ "non_empty_groups", "groups_submitted", + "has_structure_checks", + "has_extra_checks", "structure_checks_passed", "extra_checks_passed" ] diff --git a/backend/api/serializers/submission_serializer.py b/backend/api/serializers/submission_serializer.py index acdf26f8..9683ac24 100644 --- a/backend/api/serializers/submission_serializer.py +++ b/backend/api/serializers/submission_serializer.py @@ -61,6 +61,10 @@ class SubmissionSerializer(serializers.ModelSerializer): many=False, read_only=True, view_name="group-detail" ) + feedback = serializers.HyperlinkedIdentityField( + many=True, read_only=True, view_name="feedback-detail" + ) + results = CheckResultPolymorphicSerializer(many=True, read_only=True) class Meta: @@ -94,6 +98,9 @@ def validate(self, attrs): group: Group = self.context["group"] project: Project = group.project + if not project.has_started(): + raise ValidationError(_("project.error.submissions.project_not_started")) + # Check if the project's deadline is not passed. if project.deadline_passed(): raise ValidationError(_("project.error.submissions.past_project")) diff --git a/backend/api/signals.py b/backend/api/signals.py index 82bc905c..13cecafe 100644 --- a/backend/api/signals.py +++ b/backend/api/signals.py @@ -7,6 +7,7 @@ from api.models.student import Student from api.models.submission import (ExtraCheckResult, StateEnum, StructureCheckResult, Submission) +from api.models.teacher import Teacher from api.tasks.docker_image import (task_docker_image_build, task_docker_image_remove) from api.tasks.extra_check import task_extra_check_start @@ -15,6 +16,7 @@ from authentication.signals import user_created from django.db.models.signals import post_delete, post_save, pre_delete from django.dispatch import Signal, receiver +from notifications.signals import NotificationType, notification_create # MARK: Signals @@ -36,6 +38,9 @@ def _user_creation(user: User, attributes: dict, **_): if student_id is not None: Student.create(user, student_id=student_id) + else: + # For now, we assume that everyone without a student ID is a teacher. + Teacher.create(user) @receiver(run_docker_image_build) @@ -119,6 +124,13 @@ def hook_submission(sender, instance: Submission, created: bool, **kwargs): run_all_checks.send(sender=Submission, submission=instance) pass + notification_create.send( + sender=Submission, + type=NotificationType.SUBMISSION_RECEIVED, + queryset=list(instance.group.students.all()), + arguments={} + ) + @receiver(post_save, sender=DockerImage) def hook_docker_image(sender, instance: DockerImage, created: bool, **kwargs): diff --git a/backend/api/tasks/docker_image.py b/backend/api/tasks/docker_image.py index 3636ef36..b4a1eb9f 100644 --- a/backend/api/tasks/docker_image.py +++ b/backend/api/tasks/docker_image.py @@ -3,6 +3,7 @@ from api.logic.get_file_path import get_docker_image_tag from api.models.docker import DockerImage, StateEnum from celery import shared_task +from notifications.signals import NotificationType, notification_create from ypovoli.settings import MEDIA_ROOT @@ -12,18 +13,28 @@ def task_docker_image_build(docker_image: DockerImage): docker_image.state = StateEnum.BUILDING docker_image.save() + notification_type = NotificationType.DOCKER_IMAGE_BUILD_SUCCESS + # Build the image try: client = docker.from_env() client.images.build(path=MEDIA_ROOT, dockerfile=docker_image.file.path, tag=get_docker_image_tag(docker_image), rm=True, quiet=True, forcerm=True) docker_image.state = StateEnum.READY - except (docker.errors.APIError, docker.errors.BuildError, TypeError): + except (docker.errors.BuildError, docker.errors.APIError): docker_image.state = StateEnum.ERROR - # TODO: Sent notification + notification_type = NotificationType.DOCKER_IMAGE_BUILD_ERROR + finally: + # Update the state + docker_image.save() - # Update the state - docker_image.save() + # Send notification + notification_create.send( + sender=DockerImage, + type=notification_type, + queryset=[docker_image.owner], + arguments={"name": docker_image.name}, + ) @shared_task diff --git a/backend/api/tasks/extra_check.py b/backend/api/tasks/extra_check.py index 5ddb1565..1f63e87a 100644 --- a/backend/api/tasks/extra_check.py +++ b/backend/api/tasks/extra_check.py @@ -13,10 +13,10 @@ from api.models.docker import StateEnum as DockerStateEnum from api.models.submission import ErrorMessageEnum, ExtraCheckResult, StateEnum from celery import shared_task -from django.core.files import File from django.core.files.base import ContentFile from docker.models.containers import Container from docker.types import LogConfig +from notifications.signals import NotificationType, notification_create from requests.exceptions import ConnectionError @@ -36,12 +36,22 @@ def task_extra_check_start(structure_check_result: bool, extra_check_result: Ext extra_check_result.error_message = ErrorMessageEnum.DOCKER_IMAGE_ERROR extra_check_result.save() + notification_create.send( + sender=ExtraCheckResult, + type=NotificationType.EXTRA_CHECK_FAIL, + queryset=list(extra_check_result.submission.group.students.all()), + arguments={"name": extra_check_result.extra_check.name}, + ) + return structure_check_result # Will probably never happen but doesn't hurt to check while extra_check_result.submission.running_checks: sleep(1) + # Notification type + notification_type = NotificationType.EXTRA_CHECK_SUCCESS + # Lock extra_check_result.submission.running_checks = True @@ -114,41 +124,49 @@ def task_extra_check_start(structure_check_result: bool, extra_check_result: Ext case 1: extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.CHECK_ERROR + notification_type = NotificationType.EXTRA_CHECK_FAIL # Time limit case 2: extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.TIME_LIMIT + notification_type = NotificationType.EXTRA_CHECK_FAIL # Memory limit case 3: extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.MEMORY_LIMIT + notification_type = NotificationType.EXTRA_CHECK_FAIL # Catch all non zero exit codes case _: extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.RUNTIME_ERROR + notification_type = NotificationType.EXTRA_CHECK_FAIL # Docker image error except (docker.errors.APIError, docker.errors.ImageNotFound): extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.DOCKER_IMAGE_ERROR + notification_type = NotificationType.EXTRA_CHECK_FAIL # Runtime error except docker.errors.ContainerError: extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.RUNTIME_ERROR + notification_type = NotificationType.EXTRA_CHECK_FAIL # Timeout error except ConnectionError: extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.TIME_LIMIT + notification_type = NotificationType.EXTRA_CHECK_FAIL # Unknown error except Exception: extra_check_result.result = StateEnum.FAILED extra_check_result.error_message = ErrorMessageEnum.UNKNOWN + notification_type = NotificationType.EXTRA_CHECK_FAIL # Cleanup and data saving # Start by saving any logs @@ -165,6 +183,14 @@ def task_extra_check_start(structure_check_result: bool, extra_check_result: Ext extra_check_result.log_file.save(submission_uuid, content=ContentFile(logs), save=False) + # Send notification + notification_create.send( + sender=ExtraCheckResult, + type=notification_type, + queryset=list(extra_check_result.submission.group.students.all()), + arguments={"name": extra_check_result.extra_check.name}, + ) + # Zip and save any possible artifacts memory_zip = io.BytesIO() if os.listdir(artifacts_directory): diff --git a/backend/api/tasks/structure_check.py b/backend/api/tasks/structure_check.py index 8adce52a..d22cd866 100644 --- a/backend/api/tasks/structure_check.py +++ b/backend/api/tasks/structure_check.py @@ -5,6 +5,7 @@ from api.models.submission import (ErrorMessageEnum, StateEnum, StructureCheckResult) from celery import shared_task +from notifications.signals import NotificationType, notification_create @shared_task() @@ -19,6 +20,9 @@ def task_structure_check_start(structure_check_results: list[StructureCheckResul # Lock structure_check_results[0].submission.running_checks = True + # Notification type + notification_type = NotificationType.STRUCTURE_CHECK_SUCCESS + all_checks_passed = True # Boolean to check if all structure checks passed name_ext = _get_all_name_ext(structure_check_results[0].submission.zip.path) # Dict with file name and extension @@ -38,6 +42,7 @@ def task_structure_check_start(structure_check_results: list[StructureCheckResul if len(extensions) == 0: structure_check_result.result = StateEnum.FAILED structure_check_result.error_message = ErrorMessageEnum.FILE_DIR_NOT_FOUND + notification_type = NotificationType.STRUCTURE_CHECK_FAIL # Check if no blocked extension is present if structure_check_result.result == StateEnum.SUCCESS: @@ -45,6 +50,7 @@ def task_structure_check_start(structure_check_results: list[StructureCheckResul if extension.extension in extensions: structure_check_result.result = StateEnum.FAILED structure_check_result.error_message = ErrorMessageEnum.BLOCKED_EXTENSION + notification_type = NotificationType.STRUCTURE_CHECK_FAIL # Check if all obligated extensions are present if structure_check_result.result == StateEnum.SUCCESS: @@ -52,6 +58,7 @@ def task_structure_check_start(structure_check_results: list[StructureCheckResul if extension.extension not in extensions: structure_check_result.result = StateEnum.FAILED structure_check_result.error_message = ErrorMessageEnum.OBLIGATED_EXTENSION_NOT_FOUND + notification_type = NotificationType.STRUCTURE_CHECK_FAIL all_checks_passed = all_checks_passed and structure_check_result.result == StateEnum.SUCCESS structure_check_result.save() @@ -59,6 +66,14 @@ def task_structure_check_start(structure_check_results: list[StructureCheckResul # Release structure_check_results[0].submission.running_checks = False + # Send notification + notification_create.send( + sender=StructureCheckResult, + type=notification_type, + queryset=list(structure_check_results[0].submission.group.students.all()), + arguments={}, + ) + return all_checks_passed diff --git a/backend/api/tests/test_course.py b/backend/api/tests/test_course.py index 038481d1..b2c408f4 100644 --- a/backend/api/tests/test_course.py +++ b/backend/api/tests/test_course.py @@ -783,7 +783,7 @@ def test_create_course(self): "academic_startyear": 2022, "excerpt": "Excerpt", "description": "An introductory course on computer science.", - "faculty": faculty.id, + "faculty_id": faculty.id, }, follow=True, ) diff --git a/backend/api/tests/test_feedback.py b/backend/api/tests/test_feedback.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index a3fb21bd..fb2f45ad 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -8,6 +8,8 @@ from django.urls import reverse from rest_framework.test import APITestCase +from ypovoli import settings + class GroupModelTests(APITestCase): def setUp(self) -> None: @@ -39,7 +41,9 @@ def test_group_detail_view(self): content_json = json.loads(response.content.decode("utf-8")) self.assertEqual(int(content_json["id"]), group.id) - self.assertEqual(content_json["project"]["id"], group.project.id) + self.assertEqual(content_json["project"], + settings.TESTING_BASE_LINK + reverse("project-detail", args=[str(project.id)]) + ) self.assertEqual(content_json["score"], group.score) def test_group_project(self): @@ -70,11 +74,7 @@ def test_group_project(self): # Parse the JSON content from the response content_json = content_json["project"] - self.assertEqual(content_json["name"], project.name) - self.assertEqual(content_json["description"], project.description) - self.assertEqual(content_json["visible"], project.visible) - self.assertEqual(content_json["archived"], project.archived) - self.assertEqual(content_json["course"]["id"], course.id) + self.assertEqual(content_json, settings.TESTING_BASE_LINK + reverse("project-detail", args=[str(project.id)])) def test_group_students(self): """Able to retrieve students details of a group.""" diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py index 0684088e..0b28ddf8 100644 --- a/backend/api/tests/test_project.py +++ b/backend/api/tests/test_project.py @@ -5,6 +5,7 @@ from api.models.project import Project from api.models.student import Student from api.models.teacher import Teacher +from api.serializers.checks_serializer import FileExtensionSerializer from api.tests.helpers import (create_admin, create_course, create_file_extension, create_group, create_project, create_structure_check, @@ -417,22 +418,30 @@ def test_project_structure_checks_post(self): course=course, ) + obligated_extensions = FileExtensionSerializer( + [file_extension1, file_extension4], many=True + ) + + blocked_extensions = FileExtensionSerializer( + [file_extension2, file_extension3], many=True + ) + response = self.client.post( reverse("project-structure-checks", args=[str(project.id)]), - { + json.dumps({ "path": ".", - "obligated_extensions": [file_extension1.extension, file_extension4.extension], - "blocked_extensions": [file_extension2.extension, file_extension3.extension]}, + "obligated_extensions": obligated_extensions.data, + "blocked_extensions": blocked_extensions.data}), follow=True, + content_type="application/json" ) project.refresh_from_db() - self.assertEqual(response.status_code, 200) self.assertEqual(response.accepted_media_type, "application/json") # type: ignore # self.assertEqual(json.loads(response.content), {'message': gettext('project.success.structure_check.add')}) - upd: StructureCheck = project.structure_checks.all()[0] + upd: StructureCheck = project.structure_checks.first() retrieved_obligated_extensions = upd.obligated_extensions.all() retrieved_blocked_file_extensions = upd.blocked_extensions.all() @@ -472,7 +481,6 @@ def test_project_structure_checks_post_already_existing(self): days=7, course=course, ) - create_structure_check( path=".", project=project, @@ -480,13 +488,22 @@ def test_project_structure_checks_post_already_existing(self): blocked_extensions=[file_extension2, file_extension3], ) + obligated_extensions = FileExtensionSerializer( + [file_extension1, file_extension4], many=True + ) + + blocked_extensions = FileExtensionSerializer( + [file_extension2, file_extension3], many=True + ) + response = self.client.post( reverse("project-structure-checks", args=[str(project.id)]), - { + json.dumps({ "path": ".", - "obligated_extensions": [file_extension1.extension, file_extension4.extension], - "blocked_extensions": [file_extension2.extension, file_extension3.extension]}, + "obligated_extensions": obligated_extensions.data, + "blocked_extensions": blocked_extensions.data}), follow=True, + content_type="application/json" ) self.assertEqual(response.status_code, 400) @@ -513,14 +530,22 @@ def test_project_structure_checks_post_blocked_and_obligated(self): course=course, ) + obligated_extensions = FileExtensionSerializer( + [file_extension1, file_extension4], many=True + ) + + blocked_extensions = FileExtensionSerializer( + [file_extension1, file_extension2, file_extension3], many=True + ) + response = self.client.post( reverse("project-structure-checks", args=[str(project.id)]), - { + json.dumps({ "path": ".", - "obligated_extensions": [file_extension1.extension, file_extension4.extension], - "blocked_extensions": [file_extension1.extension, file_extension2.extension, - file_extension3.extension]}, + "obligated_extensions": obligated_extensions.data, + "blocked_extensions": blocked_extensions.data}), follow=True, + content_type="application/json" ) self.assertEqual(response.status_code, 400) @@ -802,7 +827,14 @@ def test_submission_status_non_empty_groups(self): # Only two of the three created groups contain at least one student self.assertEqual( content_json["status"], - {"non_empty_groups": 2, "groups_submitted": 0, "extra_checks_passed": 0, "structure_checks_passed": 0}, + { + "non_empty_groups": 2, + "groups_submitted": 0, + 'has_extra_checks': False, + 'has_structure_checks': False, + "extra_checks_passed": 0, + "structure_checks_passed": 0 + }, ) def test_submission_status_groups_submitted_and_not_passed_checks(self): @@ -866,7 +898,14 @@ def test_submission_status_groups_submitted_and_not_passed_checks(self): self.assertEqual( content_json["status"], - {"non_empty_groups": 3, "groups_submitted": 2, "extra_checks_passed": 0, "structure_checks_passed": 0}, + { + "non_empty_groups": 3, + "groups_submitted": 2, + 'has_extra_checks': False, + 'has_structure_checks': False, + "extra_checks_passed": 0, + "structure_checks_passed": 0 + }, ) def test_retrieve_list_submissions(self): diff --git a/backend/api/urls.py b/backend/api/urls.py index 85c661a6..816e0855 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -5,6 +5,7 @@ from api.views.course_view import CourseViewSet from api.views.docker_view import DockerImageViewSet from api.views.faculty_view import FacultyViewSet +from api.views.feedback_view import FeedbackViewSet from api.views.group_view import GroupViewSet from api.views.project_view import ProjectViewSet from api.views.student_view import StudentViewSet @@ -33,6 +34,8 @@ router.register(r"docker-images", DockerImageViewSet, basename="docker-image") router.register(r"structure-check-results", StructureCheckResultViewSet, basename="structure-check-result") router.register(r"extra-check-results", ExtraCheckResultViewSet, basename="extra-check-result") +router.register(r"feedback", FeedbackViewSet, basename="feedback") + urlpatterns = [ path("", include(router.urls)), diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 3adc2e6c..d95e4ace 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -11,7 +11,6 @@ AssistantSerializer) from api.serializers.course_serializer import (CourseCloneSerializer, CourseSerializer, - CreateCourseSerializer, SaveInvitationLinkSerializer, StudentJoinSerializer, StudentLeaveSerializer, @@ -41,7 +40,7 @@ class CourseViewSet(viewsets.ModelViewSet): def create(self, request: Request, *_): """Override the create method to add the teacher to the course""" - serializer = CreateCourseSerializer(data=request.data, context={"request": request}) + serializer = CourseSerializer(data=request.data, context={"request": request}) if serializer.is_valid(raise_exception=True): course = serializer.save() @@ -55,7 +54,7 @@ def create(self, request: Request, *_): def update(self, request: Request, *_, **__): """Override the update method to add the teacher to the course""" course = self.get_object() - serializer = CreateCourseSerializer(course, data=request.data, partial=True, context={"request": request}) + serializer = CourseSerializer(course, data=request.data, partial=True, context={"request": request}) if serializer.is_valid(raise_exception=True): serializer.save() @@ -198,8 +197,8 @@ def _add_student(self, request: Request, **_): individual_projects = course.projects.filter(group_size=1) for project in individual_projects: - # Check if the start date of the project is in the future - if project.start_date > timezone.now(): + # Check if the deadline of the project is in the future + if project.deadline > timezone.now(): group = Group.objects.create( project=project ) @@ -212,8 +211,8 @@ def _add_student(self, request: Request, **_): all_projects = course.projects.exclude(group_size=1) for project in all_projects: - # Check if the start date of the project is in the future - if project.start_date > timezone.now(): + # Check if the deadline of the project is in the future + if project.deadline > timezone.now(): number_groups = project.groups.count() if project.group_size * number_groups < course.students.count(): diff --git a/backend/api/views/docker_view.py b/backend/api/views/docker_view.py index 1e2da9d7..6fad8423 100644 --- a/backend/api/views/docker_view.py +++ b/backend/api/views/docker_view.py @@ -61,6 +61,14 @@ def patch_public(self, request: Request, **_) -> Response: return Response(serializer.data) + @action(detail=False, methods=['DELETE'], permission_classes=[IsAdminUser]) + def delete(self, request: Request, **_) -> Response: + response = self.queryset.filter(id__in=request.data['ids']).delete() + + return Response(response) + + # TODO: Maybe not necessary + # https://www.django-rest-framework.org/api-guide/permissions/#overview-of-access-restriction-methods def list(self, request: Request) -> Response: images: BaseManager[DockerImage] = DockerImage.objects.all() if not request.user.is_staff: diff --git a/backend/api/views/feedback_view.py b/backend/api/views/feedback_view.py new file mode 100644 index 00000000..dbf3f253 --- /dev/null +++ b/backend/api/views/feedback_view.py @@ -0,0 +1,12 @@ +from rest_framework import viewsets + +from api.models.feedback import Feedback +from api.permissions.feedback_permissions import IsAdminOrTeacherForPatch +from api.permissions.role_permissions import is_teacher +from api.serializers.feedback_serializer import FeedbackSerializer + + +class FeedbackViewSet(viewsets.ModelViewSet): + queryset = Feedback.objects.all() + serializer_class = FeedbackSerializer + permission_classes = [IsAdminOrTeacherForPatch] diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 7c118bcd..51baf860 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -9,9 +9,10 @@ from api.serializers.submission_serializer import SubmissionSerializer from django.utils.translation import gettext from drf_yasg.utils import swagger_auto_schema +from notifications.signals import NotificationType, notification_create from rest_framework.decorators import action from rest_framework.mixins import (CreateModelMixin, DestroyModelMixin, - RetrieveModelMixin, UpdateModelMixin) + RetrieveModelMixin, UpdateModelMixin, ListModelMixin) from rest_framework.permissions import IsAdminUser from rest_framework.request import Request from rest_framework.response import Response @@ -22,12 +23,29 @@ class GroupViewSet(CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin, + ListModelMixin, GenericViewSet): queryset = Group.objects.all() serializer_class = GroupSerializer permission_classes = [IsAdminUser | GroupPermission] + def update(self, request, *args, **kwargs): + old_group = self.get_object() + response = super().update(request, *args, **kwargs) + if response.status_code == 200: + new_group = self.get_object() + if "score" in request.data and old_group.score != new_group.score: + # Partial updates end up in the update function as well + notification_create.send( + sender=Group, + type=NotificationType.SCORE_UPDATED, + queryset=list(new_group.students.all()), + arguments={"score": str(new_group.score)}, + ) + + return response + @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | GroupStudentPermission]) def students(self, request, **_): """Returns a list of students for the given group""" @@ -52,6 +70,23 @@ def submissions(self, request, **_): ) return Response(serializer.data) + @submissions.mapping.post + @submissions.mapping.put + @swagger_auto_schema(request_body=SubmissionSerializer) + def _add_submission(self, request: Request, **_): + """Add a submission to the group""" + group: Group = self.get_object() + + # Add submission to course + serializer = SubmissionSerializer( + data=request.data, context={"group": group, "request": request} + ) + + if serializer.is_valid(raise_exception=True): + serializer.save(group=group) + + return Response(serializer.data) + @students.mapping.post @students.mapping.put @swagger_auto_schema(request_body=StudentJoinGroupSerializer) @@ -92,23 +127,3 @@ def _remove_student(self, request, **_): return Response({ "message": gettext("group.success.students.remove"), }) - - @submissions.mapping.post - @submissions.mapping.put - @swagger_auto_schema(request_body=SubmissionSerializer) - def _add_submission(self, request: Request, **_): - """Add a submission to the group""" - group: Group = self.get_object() - - # Add submission to course - serializer = SubmissionSerializer( - data=request.data, context={"group": group, "request": request} - ) - - if serializer.is_valid(raise_exception=True): - serializer.save(group=group) - - return Response({ - "message": gettext("group.success.submissions.add"), - "submission": serializer.data - }) diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 5f215c22..d13660e5 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -1,10 +1,11 @@ from api.models.group import Group from api.models.project import Project +from api.models.student import Student from api.models.submission import Submission from api.permissions.project_permissions import (ProjectGroupPermission, ProjectPermission) +from api.permissions.role_permissions import is_student, IsStudent from api.serializers.checks_serializer import (ExtraCheckSerializer, - StructureCheckAddSerializer, StructureCheckSerializer) from api.serializers.group_serializer import GroupSerializer from api.serializers.project_serializer import (ProjectSerializer, @@ -32,6 +33,26 @@ class ProjectViewSet(RetrieveModelMixin, serializer_class = ProjectSerializer permission_classes = [IsAdminUser | ProjectPermission] # GroupPermission has exact the same logic as for a project + @action(detail=True, permission_classes=[IsAdminUser | ProjectGroupPermission], url_path='student-group') + def student_group(self, request: Request, **_) -> Response: + """Returns the group of the student for the given project""" + + # Get the student object from the user + student = Student.objects.get(id=request.user.id) + + # Get the group of the student for the project + group = student.groups.filter(project=self.get_object()).first() + + if group is None: + return Response(None) + + # Serialize the group object + serializer = GroupSerializer( + group, context={"request": request} + ) + + return Response(serializer.data) + @action(detail=True, permission_classes=[IsAdminUser | ProjectGroupPermission]) def groups(self, request, **_): """Returns a list of groups for the given project""" @@ -86,7 +107,7 @@ def _create_groups(self, request, **_): "message": gettext("project.success.groups.created"), }) - @action(detail=True) + @action(detail=True, methods=['get']) def structure_checks(self, request, **_): """Returns the structure checks for the given project""" project = self.get_object() @@ -94,24 +115,26 @@ def structure_checks(self, request, **_): # Serialize the check objects serializer = StructureCheckSerializer( - checks, many=True, context={"request": request} + checks, + many=True, + context={ + "request": request + } ) + return Response(serializer.data) @structure_checks.mapping.post - @swagger_auto_schema(request_body=StructureCheckAddSerializer) + @swagger_auto_schema(request_body=StructureCheckSerializer) def _add_structure_check(self, request: Request, **_): """Add a structure_check to the project""" - project: Project = self.get_object() - serializer = StructureCheckAddSerializer( + serializer = StructureCheckSerializer( data=request.data, context={ "project": project, - "request": request, - "obligated": request.data.getlist('obligated_extensions') if "obligated_extensions" in request.data else [], - "blocked": request.data.getlist('blocked_extensions') if "blocked_extensions" in request.data else [] + "request": request } ) @@ -120,6 +143,30 @@ def _add_structure_check(self, request: Request, **_): return Response(serializer.data) + @structure_checks.mapping.put + @swagger_auto_schema(request_body=StructureCheckSerializer) + def _set_structure_checks(self, request: Request, **_) -> Response: + """Set the structure checks of the given project""" + project: Project = self.get_object() + + # Delete all current structure checks of the project + project.structure_checks.all().delete() + + # Create the new structure checks + serializer = StructureCheckSerializer( + data=request.data, + many=True, + context={ + 'project': project, + 'request': request + } + ) + + if serializer.is_valid(raise_exception=True): + serializer.save(project=project) + + return Response(serializer.validated_data) + @action(detail=True) def extra_checks(self, request, **_): """Returns the extra checks for the given project""" @@ -147,7 +194,6 @@ def _add_extra_check(self, request: Request, **_): } ) - # TODO: Weird error message when invalid docker_image id if serializer.is_valid(raise_exception=True): serializer.save(project=project) @@ -155,8 +201,32 @@ def _add_extra_check(self, request: Request, **_): "message": gettext("project.success.extra_check.add") }) + @extra_checks.mapping.put + @swagger_auto_schema(request_body=ExtraCheckSerializer) + def set_extra_checks(self, request: Request, **_): + """Set the extra checks of the given project""" + project: Project = self.get_object() + + # Delete all current extra checks of the project + project.extra_checks.all().delete() + + # Create the new extra checks + serializer = ExtraCheckSerializer( + data=request.data, + many=True, + context={ + "project": project, + "request": request + } + ) + + if serializer.is_valid(raise_exception=True): + serializer.save(project=project) + + return Response(serializer.validated_data) + @action(detail=True, permission_classes=[IsAdminUser | ProjectGroupPermission]) - def submission_status(self, request, **_): + def submission_status(self, _: Request): """Returns the current submission status for the given project This includes: - The total amount of groups that contain at least one student diff --git a/backend/api/views/submission_view.py b/backend/api/views/submission_view.py index 37911477..aef617d7 100644 --- a/backend/api/views/submission_view.py +++ b/backend/api/views/submission_view.py @@ -1,12 +1,3 @@ -from api.models.submission import (ExtraCheckResult, StructureCheckResult, - Submission) -from api.permissions.submission_permissions import ( - ExtraCheckResultArtifactPermission, ExtraCheckResultLogPermission, - ExtraCheckResultPermission, StructureCheckResultPermission, - SubmissionPermission) -from api.serializers.submission_serializer import ( - ExtraCheckResultSerializer, StructureCheckResultSerializer, - SubmissionSerializer) from django.http import FileResponse from django.utils.translation import gettext as _ from rest_framework.decorators import action @@ -15,6 +6,14 @@ from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet +from api.models.submission import Submission, StructureCheckResult, ExtraCheckResult +from api.permissions.submission_permissions import SubmissionPermission, StructureCheckResultPermission, \ + ExtraCheckResultPermission, ExtraCheckResultArtifactPermission, ExtraCheckResultLogPermission +from api.serializers.feedback_serializer import FeedbackSerializer +from api.serializers.submission_serializer import ( + ExtraCheckResultSerializer, StructureCheckResultSerializer, + SubmissionSerializer) + class SubmissionViewSet(RetrieveModelMixin, GenericViewSet): queryset = Submission.objects.all() @@ -30,6 +29,36 @@ def zip(self, request, **__): return FileResponse(open(submission.zip.path, "rb"), as_attachment=True) + @action(detail=True, methods=["get"]) + def feedback(self, request, **_) -> Response: + """Returns all the feedback for the given submission""" + submission = self.get_object() + feedback = submission.feedback.all() + + # Serialize the feedback object + serializer = FeedbackSerializer( + feedback, many=True, context={"request": request} + ) + + return Response(serializer.data) + + @feedback.mapping.post + @feedback.mapping.put + def _add_feedback(self, request, **_) -> Response: + """Adds feedback to the given submission""" + submission = self.get_object() + context = {"request": request, "user": request.user, "submission": submission} + serializer = FeedbackSerializer(data=request.data, context=context) + + if serializer.is_valid(): + serializer.save(submission=submission) + return Response({ + "message": "Success", + "feedback": serializer.data + }) + + return Response(serializer.errors, status=400) + class StructureCheckResultViewSet(RetrieveModelMixin, GenericViewSet): queryset = StructureCheckResult.objects.all() diff --git a/backend/api/views/user_view.py b/backend/api/views/user_view.py index 3a65fa37..b4ae6c93 100644 --- a/backend/api/views/user_view.py +++ b/backend/api/views/user_view.py @@ -57,7 +57,7 @@ def search(self, request: Request) -> Response: role_filters = Q() for role in roles: - role_filters |= Q(**{f"{role}__isnull": False, f"{role}__is_active": True}) + role_filters &= Q(**{f"{role}__isnull": False, f"{role}__is_active": True}) queryset = queryset.filter( role_filters, @@ -79,22 +79,28 @@ def search(self, request: Request) -> Response: @action(detail=True, methods=["get"], permission_classes=[NotificationPermission]) def notifications(self, request: Request, pk: str): """Returns a list of notifications for the given user""" - notifications = Notification.objects.filter(user=pk) + count = min( + int(request.query_params.get("count", 10)), 30 + ) + + # Get the notifications for the user + notifications = Notification.objects.filter(user=pk, is_read=False).order_by("-created_at") + + if notifications.count() < count: + notifications = list(notifications) + list( + Notification.objects.filter(user=pk, is_read=True).order_by("-created_at")[:count - notifications.count()] + ) + + # Serialize the notifications serializer = NotificationSerializer( notifications, many=True, context={"request": request} ) return Response(serializer.data) - @action( - detail=True, - methods=["post"], - permission_classes=[NotificationPermission], - url_path="notifications/read", - ) - def read(self, _: Request, pk: str): + @notifications.mapping.patch + def _read_notifications(self, _: Request, pk: str): """Marks all notifications as read for the given user""" notifications = Notification.objects.filter(user=pk) notifications.update(is_read=True) - return Response(status=HTTP_200_OK) diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index f7d9297b..45b6f343 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,5 +1,7 @@ from typing import Tuple +from rest_framework.relations import HyperlinkedIdentityField + from authentication.cas.client import client from authentication.models import User from authentication.signals import user_created, user_login @@ -35,7 +37,7 @@ def validate(self, data): # Update the user's last login. if api_settings.UPDATE_LAST_LOGIN: - update_last_login(self, user) + update_last_login(CASTokenObtainSerializer, user) # Login and send authentication signals. if "request" in self.context: @@ -95,11 +97,13 @@ class UserSerializer(ModelSerializer): This serializer validates the user fields for creation and updating. """ faculties = HyperlinkedRelatedField( - many=True, read_only=True, view_name="faculty-detail" + view_name="faculty-detail", + many=True, + read_only=True ) - notifications = HyperlinkedRelatedField( - view_name="notification-detail", + notifications = HyperlinkedIdentityField( + view_name="user-notifications", read_only=True, ) diff --git a/backend/authentication/views.py b/backend/authentication/views.py index a3964966..f5745821 100644 --- a/backend/authentication/views.py +++ b/backend/authentication/views.py @@ -1,3 +1,7 @@ +from api.models.assistant import Assistant +from api.models.teacher import Teacher +from django.http import HttpResponseRedirect + from authentication.cas.client import client from authentication.permissions import IsDebug from authentication.serializers import CASTokenObtainSerializer, UserSerializer @@ -21,17 +25,17 @@ class CASViewSet(ViewSet): permission_classes = [IsAuthenticated] @action(detail=False, methods=['GET'], permission_classes=[AllowAny]) - def login(self, request: Request) -> Response: + def login(self, request: Request) -> HttpResponseRedirect: """Attempt to log in. Redirect to our single CAS endpoint.""" should_echo = request.query_params.get('echo', False) - if should_echo == "1" and settings.DEBUG: + if should_echo == "1": client._service_url = settings.CAS_DEBUG_RESPONSE return redirect(client.get_login_url()) @action(detail=False, methods=['POST']) - def logout(self, request: Request) -> Response: + def logout(self, request) -> Response: """Log out the current user.""" logout(request) @@ -64,20 +68,23 @@ def echo(self, request: Request) -> Response: raise AuthenticationFailed(token_serializer.errors) -def create_user(self, request) -> Response: +def create_user(self, request, data) -> tuple[User, bool]: """General function to create a user, log them in and which returns an empty html page""" # log in user, or retrieve if they already exist - user, created = User.objects.get_or_create(id=settings.TEST_USER_DATA["id"], defaults=settings.TEST_USER_DATA) + user, created = User.objects.get_or_create(id=data["id"], defaults=data) # if it has just been created, send the signal to user_created Signal(), to also activate it as a student if created: - user_created.send(sender=self, attributes=settings.TEST_USER_ATTRIBUTES, user=user) + user_created.send(sender=self, attributes={**data, "ugentStudentID": data.id}, user=user) # login the user login(request, user) # return Response with empty html page - return Response('', + return user, created + + +response = Response('', status=HTTP_200_OK, headers={"Location": "/"}, content_type="text/html") @@ -86,14 +93,31 @@ class TestUser(ViewSet): permission_classes = [IsDebug] - @action(detail=False, methods=['GET'], permission_classes=[IsDebug], url_path='admin') - def login_admin(self, request, *__) -> Response: - """This endpoint lets you log in an admin""" - settings.TEST_USER_DATA["is_staff"] = True - return create_user(self, request) - - @action(detail=False, methods=['GET'], permission_classes=[IsDebug], url_path='student') + @action(detail=False, methods=['POST'], permission_classes=[IsDebug], url_path='student') def login_student(self, request, *__) -> Response: """This endpoint lets you log in as a student who's not an admin""" - settings.TEST_USER_DATA["is_staff"] = False - return create_user(self, request) + create_user(self, request, settings.TEST_STUDENT_DATA) + return response + + @action(detail=False, methods=['POST'], permission_classes=[IsDebug], url_path='professor') + def login_professor(self, request, *__) -> Response: + """This endpoint lets you log in as a professor""" + user, created = create_user(self, request, settings.TEST_PROFESSOR_DATA) + if created: + Teacher.create(user) + return response + + @action(detail=False, methods=['POST'], permission_classes=[IsDebug], url_path='multi') + def login_multi(self, request, *__) -> Response: + """This endpoint lets you log in as a user who's a student, an assistant and a professor at the same time""" + user, created = create_user(self, request, settings.TEST_MULTI_DATA) + if created: + Assistant.create(user) + Teacher.create(user) + return response + + @action(detail=False, methods=['POST'], permission_classes=[IsDebug], url_path='admin') + def login_admin(self, request, *__) -> Response: + """This endpoint lets you log in an admin""" + create_user(self, request, settings.TEST_ADMIN_DATA) + return response diff --git a/backend/data/fixtures/realistic/projects/0/0/checks/generate_gibberish.sh b/backend/data/fixtures/realistic/projects/0/0/checks/generate_gibberish.sh index 9690ec1e..2deb94d8 100755 --- a/backend/data/fixtures/realistic/projects/0/0/checks/generate_gibberish.sh +++ b/backend/data/fixtures/realistic/projects/0/0/checks/generate_gibberish.sh @@ -19,4 +19,4 @@ done # Generate an artifact -wget https://golang.org/doc/gopher/modelsheet.jpg -P artifacts +wget https://upload.wikimedia.org/wikipedia/commons/5/5e/Logo_UGent_NL_RGB_2400_kleur-op-wit.png -P artifacts diff --git a/backend/notifications/fixtures/realistic/realistic.yaml b/backend/notifications/fixtures/realistic/realistic.yaml index e69de29b..0e37a039 100644 --- a/backend/notifications/fixtures/realistic/realistic.yaml +++ b/backend/notifications/fixtures/realistic/realistic.yaml @@ -0,0 +1,45 @@ +- model: notifications.notificationtemplate + pk: 1 + fields: + title_key: "Title: Score added" + description_key: "Description: Score added %(score)s" +- model: notifications.notificationtemplate + pk: 2 + fields: + title_key: "Title: Score updated" + description_key: "Description: Score updated %(score)s" +- model: notifications.notificationtemplate + pk: 3 + fields: + title_key: "Title: Docker image build success" + description_key: "Description: Docker image build success %(name)s" +- model: notifications.notificationtemplate + pk: 4 + fields: + title_key: "Title: Docker image build error" + description_key: "Description: Docker image build error %(name)s" +- model: notifications.notificationtemplate + pk: 5 + fields: + title_key: "Title: Extra check success" + description_key: "Description: Extra check success %(name)s" +- model: notifications.notificationtemplate + pk: 6 + fields: + title_key: "Title: Extra check error" + description_key: "Description: Extra check error %(name)s" +- model: notifications.notificationtemplate + pk: 7 + fields: + title_key: "Title: Structure checks success" + description_key: "Description: Structure checks success" +- model: notifications.notificationtemplate + pk: 8 + fields: + title_key: "Title: Structure checks error" + description_key: "Description: Structure checks" +- model: notifications.notificationtemplate + pk: 9 + fields: + title_key: "Title: Submission received" + description_key: "Description: Submission received" diff --git a/backend/notifications/locale/en/LC_MESSAGES/django.po b/backend/notifications/locale/en/LC_MESSAGES/django.po index 465520e9..bff878ed 100644 --- a/backend/notifications/locale/en/LC_MESSAGES/django.po +++ b/backend/notifications/locale/en/LC_MESSAGES/django.po @@ -30,3 +30,38 @@ msgid "Title: Score updated" msgstr "New score" msgid "Description: Score updated %(score)s" msgstr "Your score has been updated.\nNew score: %(score)s" +# Docker Image Build Succes +msgid "Title: Docker image build success" +msgstr "Docker image successfully build" +msgid "Description: Docker image build success %(name)s" +msgstr "Your docker image, %(name)s, has successfully been build" +# Docker Image Build Error +msgid "Title: Docker image build error" +msgstr "Docker image failed to build" +msgid "Description: Docker image build error %(name)s" +msgstr "Failed to build your docker image, %(name)s" +# Extra Check Succes +msgid "Title: Extra check success" +msgstr "Passed an extra check" +msgid "Description: Extra check success %(name)s" +msgstr "Your submission passed the extra check, %(name)s" +# Extra Check Error +msgid "Title: Extra check error" +msgstr "Failed an extra check" +msgid "Description: Extra check error %(name)s" +msgstr "Your submission failed to pass the extra check, %(name)s" +# Structure Checks Succes +msgid "Title: Structure checks success" +msgstr "Passed all structure checks" +msgid "Description: Structure checks success" +msgstr "Your submission passed all structure checks" +# Structure Checks Error +msgid "Title: Structure checks error" +msgstr "Failed a structure check" +msgid "Description: Structure checks" +msgstr "Your submission failed one or more structure checks" +# Submission received +msgid "Title: Submission received" +msgstr "Received submission" +msgid "Description: Submission received" +msgstr "We have received your submission" diff --git a/backend/notifications/locale/nl/LC_MESSAGES/django.po b/backend/notifications/locale/nl/LC_MESSAGES/django.po index 5a854108..6796a0d0 100644 --- a/backend/notifications/locale/nl/LC_MESSAGES/django.po +++ b/backend/notifications/locale/nl/LC_MESSAGES/django.po @@ -30,3 +30,38 @@ msgid "Title: Score updated" msgstr "Nieuwe score" msgid "Description: Score updated %(score)s" msgstr "Je score is geupdate.\nNieuwe score: %(score)s" +# Docker Image Build Succes +msgid "Title: Docker image build success" +msgstr "Docker image succesvol gebouwd" +msgid "Description: Docker image build success %(name)s" +msgstr "Jouw docker image, %(name)s, is succesvol gebouwd" +# Docker Image Build Error +msgid "Title: Docker image build error" +msgstr "Docker image is gefaald om te bouwen" +msgid "Description: Docker image build error %(name)s" +msgstr "Gefaald om jouw docker image, %(name)s, te bouwen" +# Extra Check Succes +msgid "Title: Extra check success" +msgstr "Geslaagd voor een extra check" +msgid "Description: Extra check success %(name)s" +msgstr "Jouw indiening is geslaagd voor de extra check: %(name)s" +# Extra Check Error +msgid "Title: Extra check error" +msgstr "Gefaald voor een extra check" +msgid "Description: Extra check error %(name)s" +msgstr "Jouw indiening is gefaald voor de extra check: %(name)s" +# Structure Checks Succes +msgid "Title: Structure checks success" +msgstr "Geslaagd voor de structuur checks" +msgid "Description: Structure checks success" +msgstr "Jouw indiening is geslaagd voor alle structuur checks" +# Structure Checks Error +msgid "Title: Structure checks error" +msgstr "Gefaald voor een structuur check" +msgid "Description: Structure checks" +msgstr "Jouw indiening is gefaald voor een structuur check" +# Submission received +msgid "Title: Submission received" +msgstr "Indiening ontvangen" +msgid "Description: Submission received" +msgstr "We hebben jouw indiening ontvangen" diff --git a/backend/notifications/logic.py b/backend/notifications/logic.py index e2061a87..a9544995 100644 --- a/backend/notifications/logic.py +++ b/backend/notifications/logic.py @@ -11,8 +11,8 @@ from ypovoli.settings import EMAIL_CUSTOM -# Returns a dictionary with the title and description of the notification def get_message_dict(notification: Notification) -> Dict[str, str]: + """Get the message from the template and arguments.""" return { "title": _(notification.template_id.title_key), "description": _(notification.template_id.description_key) @@ -20,17 +20,17 @@ def get_message_dict(notification: Notification) -> Dict[str, str]: } -# Call the function after 60 seconds and no more than once in that period def schedule_send_mails(): - if not cache.get("notifications_send_mails"): + """Schedule the sending of emails.""" + if not cache.get("notifications_send_mails", False): cache.set("notifications_send_mails", True) _send_mails.apply_async(countdown=60) -# Try to send one email and set the result -def _send_mail(mail: mail.EmailMessage, result: List[bool]): +def _send_mail(message: mail.EmailMessage, result: List[bool]): + """Try to send one email and set the result.""" try: - mail.send(fail_silently=False) + message.send(fail_silently=False) result[0] = True except SMTPException: result[0] = False @@ -40,11 +40,13 @@ def _send_mail(mail: mail.EmailMessage, result: List[bool]): # TODO: Move to tasks module # TODO: Retry 3 # https://docs.celeryq.dev/en/v5.3.6/getting-started/next-steps.html#next-steps -# Send all unsent emails -@shared_task(ignore_result=True) +@shared_task() def _send_mails(): + """Send all unsent emails.""" + # All notifications that need to be sent notifications = Notification.objects.filter(is_sent=False) + # Dictionary with the number of errors for each email errors: DefaultDict[str, int] = cache.get( "notifications_send_mails_errors", defaultdict(int) @@ -105,5 +107,6 @@ def _send_mails(): # Restart the process if there are any notifications left that were not sent unsent_notifications = Notification.objects.filter(is_sent=False) cache.set("notifications_send_mails", False) + if unsent_notifications.count() > 0: schedule_send_mails() diff --git a/backend/notifications/migrations/0002_alter_notification_user.py b/backend/notifications/migrations/0002_alter_notification_user.py new file mode 100644 index 00000000..1ddeae05 --- /dev/null +++ b/backend/notifications/migrations/0002_alter_notification_user.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.4 on 2024-05-23 10:51 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='notification', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/notifications/models.py b/backend/notifications/models.py index c9c2fbde..9ff69cc1 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -3,27 +3,53 @@ class NotificationTemplate(models.Model): - id = models.AutoField(auto_created=True, primary_key=True) - title_key = models.CharField(max_length=255) # Key used to get translated title + """This model represents a template for a notification.""" + id = models.AutoField( + auto_created=True, + primary_key=True + ) + title_key = models.CharField( + max_length=255 + ) description_key = models.CharField( max_length=511 - ) # Key used to get translated description + ) class Notification(models.Model): - id = models.AutoField(auto_created=True, primary_key=True) - user = models.ForeignKey(User, on_delete=models.CASCADE) - template_id = models.ForeignKey(NotificationTemplate, on_delete=models.CASCADE) - created_at = models.DateTimeField(auto_now_add=True) - arguments = models.JSONField(default=dict) # Arguments to be used in the template + """This model represents a notification.""" + id = models.AutoField( + auto_created=True, + primary_key=True + ) + + user = models.ForeignKey( + User, + related_name="notifications", + on_delete=models.CASCADE + ) + + template_id = models.ForeignKey( + NotificationTemplate, + on_delete=models.CASCADE + ) + created_at = models.DateTimeField( + auto_now_add=True + ) + # Arguments to be used in the template + arguments = models.JSONField( + default=dict + ) + # Whether the notification has been read is_read = models.BooleanField( default=False - ) # Whether the notification has been read + ) + # Whether the notification has been sent (email) is_sent = models.BooleanField( default=False - ) # Whether the notification has been sent (email) + ) - # Mark the notification as read def sent(self): + """Mark the notification as sent""" self.is_sent = True self.save() diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py index d4c488ba..351c9c16 100644 --- a/backend/notifications/serializers.py +++ b/backend/notifications/serializers.py @@ -1,8 +1,5 @@ -import re -from typing import Dict, List - from authentication.models import User -from notifications.logic import get_message_dict +from django.utils.translation import gettext as _ from notifications.models import Notification, NotificationTemplate from rest_framework import serializers @@ -16,58 +13,31 @@ class Meta: class NotificationSerializer(serializers.ModelSerializer): # Hyper linked user field user = serializers.HyperlinkedRelatedField( - view_name="user-detail", queryset=User.objects.all() + view_name="user-detail", + queryset=User.objects.all() ) - # Translate template and arguments into a message - message = serializers.SerializerMethodField() - - # Check if the required arguments are present - def _get_missing_keys(self, string: str, arguments: Dict[str, str]) -> List[str]: - required_keys: List[str] = re.findall(r"%\((\w+)\)", string) - missing_keys = [key for key in required_keys if key not in arguments] - - return missing_keys - - def validate(self, data: Dict[str, str]) -> Dict[str, str]: - data: Dict[str, str] = super().validate(data) - - # Validate the arguments - if "arguments" not in data: - data["arguments"] = {} - - title_missing = self._get_missing_keys( - data["template_id"].title_key, data["arguments"] - ) - description_missing = self._get_missing_keys( - data["template_id"].description_key, data["arguments"] - ) - - if title_missing or description_missing: - raise serializers.ValidationError( - { - "missing arguments": { - "title": title_missing, - "description": description_missing, - } - } - ) + title = serializers.SerializerMethodField() + content = serializers.SerializerMethodField() + arguments = serializers.JSONField(write_only=True) - return data + def get_content(self, notification: Notification) -> str: + """Get the content from the template and arguments.""" + return _(notification.template_id.description_key) % notification.arguments - # Get the message from the template and arguments - def get_message(self, obj: Notification) -> Dict[str, str]: - return get_message_dict(obj) + def get_title(self, notification: Notification) -> str: + """Get the title from the template and arguments.""" + return _(notification.template_id.title_key) class Meta: model = Notification fields = [ "id", "user", - "template_id", - "arguments", - "message", + "content", + "title", "created_at", "is_read", "is_sent", + "arguments" ] diff --git a/backend/notifications/signals.py b/backend/notifications/signals.py index f8203f39..15cad5e0 100644 --- a/backend/notifications/signals.py +++ b/backend/notifications/signals.py @@ -4,39 +4,43 @@ from typing import Dict, List, Union from authentication.models import User -from django.db.models.query import QuerySet from django.dispatch import Signal, receiver from django.urls import reverse from notifications.logic import schedule_send_mails from notifications.serializers import NotificationSerializer +from ypovoli.settings import TESTING notification_create = Signal() @receiver(notification_create) def notification_creation( + sender: type, type: NotificationType, - queryset: QuerySet[User], + queryset: list[User], arguments: Dict[str, str], **kwargs, # Required by django ) -> bool: + if TESTING: + return True + data: List[Dict[str, Union[str, int, Dict[str, str]]]] = [] for user in queryset: - data.append( - { - "template_id": type.value, - "user": reverse("user-detail", kwargs={"pk": user.id}), - "arguments": arguments, - } - ) + if user: + data.append( + { + "user": reverse("user-detail", kwargs={"pk": user.id}), + "arguments": arguments, + } + ) serializer = NotificationSerializer(data=data, many=True) - if not serializer.is_valid(): + if not serializer.is_valid(raise_exception=False): return False - serializer.save() + serializer.save(template_id_id=type.value) schedule_send_mails() @@ -46,3 +50,10 @@ def notification_creation( class NotificationType(Enum): SCORE_ADDED = 1 # Arguments: {"score": int} SCORE_UPDATED = 2 # Arguments: {"score": int} + DOCKER_IMAGE_BUILD_SUCCESS = 3 # Arguments: {"name": str} + DOCKER_IMAGE_BUILD_ERROR = 4 # Arguments: {"name": str} + EXTRA_CHECK_SUCCESS = 5 # Arguments: {"name": str} + EXTRA_CHECK_FAIL = 6 # Arguments: {"name": str} + STRUCTURE_CHECK_SUCCESS = 7 # Arguments: {} + STRUCTURE_CHECK_FAIL = 8 # Arguments: {} + SUBMISSION_RECEIVED = 9 # Arguments: {} diff --git a/backend/poetry.lock b/backend/poetry.lock index efb7f1f0..de94c864 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1317,13 +1317,13 @@ ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)" [[package]] name = "requests" -version = "2.31.0" +version = "2.32.0" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.0-py3-none-any.whl", hash = "sha256:f2c3881dddb70d056c5bd7600a4fae312b2a300e39be6a118d30b90bd27262b5"}, + {file = "requests-2.32.0.tar.gz", hash = "sha256:fa5490319474c82ef1d2c9bc459d3652e3ae4ef4c4ebdd18a21145a47ca4b6b8"}, ] [package.dependencies] @@ -1626,4 +1626,4 @@ brotli = ["Brotli"] [metadata] lock-version = "2.0" python-versions = "^3.11.4" -content-hash = "eb154813d38b776ea62b72172e5127abd79f4006005d097421c14dfe40c557df" +content-hash = "fe59ecb1d9eb60d2f1cce90067f18cf9cac4748498c357640a8ab39f38a9a9e5" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3e5e66a0..d1eba8fc 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,7 +13,7 @@ django-sslserver = "^0.22" djangorestframework = "^3.15.1" django-rest-swagger = "^2.2.0" drf-yasg = "^1.21.7" -requests = "^2.31.0" +requests = "^2.32.0" cas-client = "^1.0.0" psycopg2-binary = "^2.9.9" djangorestframework-simplejwt = "^5.3.1" diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index 259f96d6..223fcc2f 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -27,17 +27,36 @@ MEDIA_ROOT = os.path.normpath(os.path.join("data")) # TESTING +TESTING = environ.get("DJANGO_TESTING", "False").lower() in ["true", "1", "t"] TESTING_BASE_LINK = "http://testserver" -TEST_USER_DATA = { - "id": "1234", - "username": "test", - "email": "test@test", - "first_name": "test", - "last_name": "test", +TEST_ADMIN_DATA = { + "id": "0", + "username": "admin", + "email": "admin@test", + "first_name": "admin", + "last_name": "admin", + "is_staff": True } -TEST_USER_ATTRIBUTES = { - **TEST_USER_DATA, - "ugentStudentID": "1234" +TEST_STUDENT_DATA = { + "id": "6", + "username": "student", + "email": "student@test", + "first_name": "student", + "last_name": "student", +} +TEST_PROFESSOR_DATA = { + "id": "1", + "username": "professor", + "email": "professor@test", + "first_name": "professor", + "last_name": "professor", +} +TEST_MULTI_DATA = { + "id": "10", + "username": "multi", + "email": "multi@test", + "first_name": "multi", + "last_name": "multi", } # SECURITY WARNING: keep the secret key used in production secret! @@ -125,6 +144,34 @@ }, } +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'simple', + }, + }, + 'formatters': { + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'DEBUG', + }, + 'loggers': { + 'ypovoli': { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': False, + } + }, +} + # Default primary key field type # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/development.sh b/development.sh index 70dfe9fe..df298669 100755 --- a/development.sh +++ b/development.sh @@ -134,8 +134,7 @@ if [ "$data" != "" ]; then docker-compose -f development.yml up -d backend redis celery echo "Clearing, Migrating & Populating the database" - # We have nog fixtures for notification yet. - docker-compose -f development.yml run backend sh -c "python manage.py flush --no-input; python manage.py migrate; python manage.py loaddata authentication/fixtures/$data/*; python manage.py loaddata api/fixtures/$data/*;" + docker-compose -f development.yml run backend sh -c "python manage.py flush --no-input; python manage.py migrate; python manage.py loaddata notifications/fixtures/$data/*; python manage.py loaddata authentication/fixtures/$data/*; python manage.py loaddata api/fixtures/$data/*;" echo "Stopping the services" docker-compose -f development.yml down diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts old mode 100755 new mode 100644 index c2f69b82..39015018 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -24,18 +24,8 @@ export default defineConfig({ // { text: 'Examples', link: '/markdown-examples' } ], - sidebar: [ - { - text: "Algemeen", - items: [ - // { text: 'Markdown Examples', link: '/markdown-examples' }, - // { text: 'Runtime API Examples', link: '/api-examples' } - ], - }, - ], - socialLinks: [ { icon: "github", link: "https://github.com/SELab-2/UGent-7" }, ], }, -}); +}) \ No newline at end of file diff --git a/docs/en/admin-examples.md b/docs/en/admin-examples.md index d38eed01..9505de7e 100644 --- a/docs/en/admin-examples.md +++ b/docs/en/admin-examples.md @@ -20,7 +20,7 @@ To further navigate to the admin pannel you can add "/admin". - Click on your name in the navigation bar. -![logout button](../assets/en/logout-button.png) +![logout button](../assets/ ../assets/en/logout-button.png) ## Change Language diff --git a/docs/en/api-examples.md b/docs/en/api-examples.md deleted file mode 100644 index 6bd8bb5c..00000000 --- a/docs/en/api-examples.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -outline: deep ---- - -# Runtime API Examples - -This page demonstrates usage of some of the runtime APIs provided by VitePress. - -The main `useData()` API can be used to access site, theme, and page data for the current page. It works in both `.md` and `.vue` files: - -```md - - -## Results - -### Theme Data -
{{ theme }}
- -### Page Data -
{{ page }}
- -### Page Frontmatter -
{{ frontmatter }}
-``` - - - -## Results - -### Theme Data -
{{ theme }}
- -### Page Data -
{{ page }}
- -### Page Frontmatter -
{{ frontmatter }}
- -## More - -Check out the documentation for the [full list of runtime APIs](https://vitepress.dev/reference/runtime-api#usedata). diff --git a/docs/en/index.md b/docs/en/index.md index c22a4bae..fac2f489 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -4,8 +4,7 @@ layout: home hero: name: "Ypovoli" - text: "TODO" - tagline: TODO + text: Help pagina actions: - theme: brand text: Student @@ -19,13 +18,5 @@ hero: - theme: brand text: Admin link: /en/admin-examples - -features: - - title: Feature A - details: lorem english - - title: Feature B - details: Lorem ipsum dolor sit amet, consectetur adipiscing elit - - title: Feature C - details: Lorem ipsum dolor sit amet, consectetur adipiscing elit --- diff --git a/docs/en/markdown-examples.md b/docs/en/markdown-examples.md deleted file mode 100644 index f9258a55..00000000 --- a/docs/en/markdown-examples.md +++ /dev/null @@ -1,85 +0,0 @@ -# Markdown Extension Examples - -This page demonstrates some of the built-in markdown extensions provided by VitePress. - -## Syntax Highlighting - -VitePress provides Syntax Highlighting powered by [Shiki](https://github.com/shikijs/shiki), with additional features like line-highlighting: - -**Input** - -````md -```js{4} -export default { - data () { - return { - msg: 'Highlighted!' - } - } -} -``` -```` - -**Output** - -```js{4} -export default { - data () { - return { - msg: 'Highlighted!' - } - } -} -``` - -## Custom Containers - -**Input** - -```md -::: info -This is an info box. -::: - -::: tip -This is a tip. -::: - -::: warning -This is a warning. -::: - -::: danger -This is a dangerous warning. -::: - -::: details -This is a details block. -::: -``` - -**Output** - -::: info -This is an info box. -::: - -::: tip -This is a tip. -::: - -::: warning -This is a warning. -::: - -::: danger -This is a dangerous warning. -::: - -::: details -This is a details block. -::: - -## More - -Check out the documentation for the [full list of markdown extensions](https://vitepress.dev/guide/markdown). diff --git a/docs/en/student-examples.md b/docs/en/student-examples.md index 838cc994..0e555eb3 100644 --- a/docs/en/student-examples.md +++ b/docs/en/student-examples.md @@ -93,9 +93,6 @@ This page describes how to interact with Ypovoli as a student. - Scroll to the "My Courses" section. - Click on the course of the desired project. - Under the "Current Projects" section, you will see all projects for this specific course. -- Option 4: - - Click on "Projects" in the navigation bar. - - You will see an overview of all your projects. ::: info Project card explanation ![project card](../assets/en/project-card.png) @@ -135,4 +132,4 @@ This page describes how to interact with Ypovoli as a student. ## View Previous Submissions Status - Go to the submission page. -- It says there. !!! TODO !!! +- It says there. diff --git a/docs/nl/api-examples.md b/docs/nl/api-examples.md deleted file mode 100644 index 6bd8bb5c..00000000 --- a/docs/nl/api-examples.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -outline: deep ---- - -# Runtime API Examples - -This page demonstrates usage of some of the runtime APIs provided by VitePress. - -The main `useData()` API can be used to access site, theme, and page data for the current page. It works in both `.md` and `.vue` files: - -```md - - -## Results - -### Theme Data -
{{ theme }}
- -### Page Data -
{{ page }}
- -### Page Frontmatter -
{{ frontmatter }}
-``` - - - -## Results - -### Theme Data -
{{ theme }}
- -### Page Data -
{{ page }}
- -### Page Frontmatter -
{{ frontmatter }}
- -## More - -Check out the documentation for the [full list of runtime APIs](https://vitepress.dev/reference/runtime-api#usedata). diff --git a/docs/nl/index.md b/docs/nl/index.md index 9a981c2e..a73bf877 100644 --- a/docs/nl/index.md +++ b/docs/nl/index.md @@ -4,8 +4,7 @@ layout: home hero: name: "Ypovoli" - text: "TODO" - tagline: TODO + text: Help page actions: - theme: brand text: Student @@ -19,13 +18,5 @@ hero: - theme: brand text: Admin link: /nl/admin-examples - -features: - - title: Feature A - details: lorem nederlands - - title: Feature B - details: Lorem ipsum dolor sit amet, consectetur adipiscing elit - - title: Feature C - details: Lorem ipsum dolor sit amet, consectetur adipiscing elit --- diff --git a/docs/nl/markdown-examples.md b/docs/nl/markdown-examples.md deleted file mode 100644 index f9258a55..00000000 --- a/docs/nl/markdown-examples.md +++ /dev/null @@ -1,85 +0,0 @@ -# Markdown Extension Examples - -This page demonstrates some of the built-in markdown extensions provided by VitePress. - -## Syntax Highlighting - -VitePress provides Syntax Highlighting powered by [Shiki](https://github.com/shikijs/shiki), with additional features like line-highlighting: - -**Input** - -````md -```js{4} -export default { - data () { - return { - msg: 'Highlighted!' - } - } -} -``` -```` - -**Output** - -```js{4} -export default { - data () { - return { - msg: 'Highlighted!' - } - } -} -``` - -## Custom Containers - -**Input** - -```md -::: info -This is an info box. -::: - -::: tip -This is a tip. -::: - -::: warning -This is a warning. -::: - -::: danger -This is a dangerous warning. -::: - -::: details -This is a details block. -::: -``` - -**Output** - -::: info -This is an info box. -::: - -::: tip -This is a tip. -::: - -::: warning -This is a warning. -::: - -::: danger -This is a dangerous warning. -::: - -::: details -This is a details block. -::: - -## More - -Check out the documentation for the [full list of markdown extensions](https://vitepress.dev/guide/markdown). diff --git a/docs/nl/student-examples.md b/docs/nl/student-examples.md index 569e2179..4695c23c 100644 --- a/docs/nl/student-examples.md +++ b/docs/nl/student-examples.md @@ -93,9 +93,6 @@ Deze pagina beschrijft hoe u als student met Ypovoli interageert. - Scrol naar de sectie "Mijn vakken". - Druk hier op het vak van het gezochte project. - Onder de sectie "Lopende projecten" ziet u alle projecten voor dit specifieke vak. -- Optie 4: - - Druk in de navigatiebalk op "Projecten". - - U ziet een overzicht van al uw projecten. ::: info Project kaart uitleg @@ -136,4 +133,4 @@ De kaart is als volgt ingedeeld: ## Status vorige indieningen bekijken - Ga naar indien pagina. -- Staat daar bij. !!! TODO !!! +- Staat daar bij. diff --git a/frontend/cypress.config.js b/frontend/cypress.config.js index 83abcaba..fbde2fec 100644 --- a/frontend/cypress.config.js +++ b/frontend/cypress.config.js @@ -1,7 +1,13 @@ import { defineConfig } from 'cypress'; +import vitePreprocessor from 'cypress-vite'; export default defineConfig({ e2e: { + setupNodeEvents(on, config) { + on('file:preprocessor', + vitePreprocessor('./vite.config.ts') + ); + }, baseUrl: 'https://nginx', specPattern: 'src/test/e2e/**/*.cy.{js,jsx,ts,tsx}', }, diff --git a/frontend/cypress/support/e2e.ts b/frontend/cypress/support/e2e.ts index f80f74f8..f553c558 100644 --- a/frontend/cypress/support/e2e.ts +++ b/frontend/cypress/support/e2e.ts @@ -14,7 +14,45 @@ // *********************************************************** // Import commands.js using ES2015 syntax: -import './commands' +import './commands.ts' // Alternatively you can use CommonJS syntax: -// require('./commands') \ No newline at end of file +// require('./commands') + +Cypress.on('uncaught:exception', (err, runnable) => { + // log uncaught error + console.error('Uncaught exception:', err.message); + + return !err.message.includes('401'); +}); + +const logout = () => { + cy.getCookie('csrftoken').then((cookie) => { + cy.getCookie('sessionid').then((cookie2) => { + if (cookie && cookie2) { + cy.request({ + method: 'POST', + url: '/api/auth/cas/logout/', + headers: { + 'Referer': Cypress.config('baseUrl'), + 'X-CSRFToken': cookie.value, + }, + }); + } + }) + + }); +} + +// before(() => { +// cy.request('POST', '/api/auth/test-user/student/'); +// logout(); +// cy.request('POST', '/api/auth/test-user/multi/'); +// logout(); +// cy.request('POST', '/api/auth/test-user/admin/'); +// logout(); +// }) + +afterEach(() => { + logout(); +}) diff --git a/frontend/index.html b/frontend/index.html index 9159f21b..ddce0f45 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + Ypovoli diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d88cf18d..b38c986e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,7 +19,7 @@ "pinia": "^2.1.7", "primeflex": "^3.3.1", "primeicons": "^7.0.0", - "primevue": "^3.50.0", + "primevue": "^3.52.0", "quill": "^1.3.7", "vue": "^3.4.18", "vue-i18n": "^9.10.2", @@ -33,6 +33,7 @@ "@typescript-eslint/eslint-plugin": "^6.21.0", "@vitejs/plugin-vue": "^5.0.4", "cypress": "^13.7.1", + "cypress-vite": "^1.5.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-config-standard-with-typescript": "^43.0.1", @@ -2812,6 +2813,19 @@ "node": "^16.0.0 || ^18.0.0 || >=20.0.0" } }, + "node_modules/cypress-vite": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/cypress-vite/-/cypress-vite-1.5.0.tgz", + "integrity": "sha512-vvTMqJZgI3sN2ylQTi4OQh8LRRjSrfrIdkQD5fOj+EC/e9oHkxS96lif1SyDF1PwailG1tnpJE+VpN6+AwO/rg==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.3", + "debug": "^4.3.4" + }, + "peerDependencies": { + "vite": "^2.9.0 || ^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/cypress/node_modules/proxy-from-env": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 833a4f53..a6a82480 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "test": "vitest run", "cypress:open": "cypress open", "cypress:test": "cypress run", + "cypress:install": "cypress install", "lint": "eslint src --ext .ts,.vue", "lint-fix": "eslint src --ext .ts,.vue --fix" }, @@ -26,7 +27,7 @@ "pinia": "^2.1.7", "primeflex": "^3.3.1", "primeicons": "^7.0.0", - "primevue": "^3.50.0", + "primevue": "^3.52.0", "quill": "^1.3.7", "vue": "^3.4.18", "vue-i18n": "^9.10.2", @@ -40,6 +41,7 @@ "@typescript-eslint/eslint-plugin": "^6.21.0", "@vitejs/plugin-vue": "^5.0.4", "cypress": "^13.7.1", + "cypress-vite": "^1.5.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-config-standard-with-typescript": "^43.0.1", diff --git a/frontend/src/assets/img/favicon.ico b/frontend/src/assets/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b0bf7ad075b5cbfd569dae24d78b4ff5a1173778 GIT binary patch literal 1150 zcmdth%_~G<6vy#%nKVinrAZb-VPxSYjfJ;SET%+xw^2l7p(Yk6nUsYnFJ+}fk=K;M zSdgu<6aR-l!S^>aSJTuiSZeyrdFFY}bMLw5K4a$a@%fDJQk(IZ`OPcQRR#Xs#7y$cB30$1POTHHL7Eacp4`3)sdOPSFPKb@x9qRu5GFD9$m3J*;2} z`Sv&7&LZXn_0tdVlJjj3nc5GU{CnVgMQ|M{AYP z{w(O4Cs5DNGQ{+?Ir4I`fm!J6;qCl#p7a literal 0 HcmV?d00001 diff --git a/frontend/src/assets/lang/app/en.json b/frontend/src/assets/lang/app/en.json index bafc4d00..3fa469d6 100644 --- a/frontend/src/assets/lang/app/en.json +++ b/frontend/src/assets/lang/app/en.json @@ -2,21 +2,33 @@ "layout": { "header": { "logo": "Ghent University logo", - "login": "login", + "login": "Login", "view": "View as {0}", "user": "Logged in as {0}", "navigation": { "dashboard": "Dashboard", "calendar": "Calendar", - "courses": "courses", - "projects": "projects", - "settings": "preferences", - "help": "help" + "courses": "Courses", + "projects": "Projects" }, "language": { "nl": "Nederlands", "en": "English" + }, + "notifications": { + "new": "New", + "title": "Notifications", + "markAsRead": "Mark as read", + "loadMore": "Load more", + "noNotifications": "No notifications available" } + }, + "footer": { + "home": "Dashboard", + "about": "Help", + "privacy": "Cookies", + "contact": "Contact", + "rights": "All rights reserved" } }, "views": { @@ -43,16 +55,23 @@ }, "projects": { "all": "All projects", + "backToCourse": "Back to course", "coming": "Near deadlines", "deadline": "Deadline", "days": "Today at {hour} | Tomorrow at {hour} | In {count} days", - "ago": "{count} days ago", + "ago": "1 day ago | {count} days ago", + "chooseGroupMessage": "Choose a group before {0}", + "groupScore": "Group score", + "noGroupScore": "No group score", + "noGroupMembers": "No group members", + "publishScores": "Publish scores", "groupName": "Group name", "groupPopulation": "Size", "groupStatus": "Status", "start": "Start date", "submissionStatus": "Submission status", "group": "Group", + "groups": "Groups", "groupSize": "Individual | Groups of {count} people", "noGroups": "No groups available", "groupMembers": "Group members", @@ -60,37 +79,56 @@ "joinGroup": "Join group", "leaveGroup": "Leave group", "create": "Create new project", - "edit": "Edit project", + "save": "Save project", + "edit": "Save project", "name": "Project name", "description": "Description", - "start_date": "Start project", - "group_size": "Number of students in a group (1 for an individual project)", - "number_of_groups": "Number of groups (optional, otherwise #students / group size)", - "max_score": "Maximum score that can be achieved", + "startDate": "Start project", + "numberStudentsGroup": "Number of students in a group (1 for an individual project)", + "numberOfGroups": "Number of groups (optional, otherwise #students / group size)", + "maxScore": "Maximum score that can be achieved", "visibility": "Make project visible to students", "scoreVisibility": "Make score, when uploaded, automatically visible to students", "submissionStructure": "Structure of how a submission should be made", "noStudents": "No students in this group", "locked": "Closed", "unlocked": "Open", + "structureChecks": { + "title": "Structure checks", + "placeholder": "Give a name to this folder", + "cancelSelection": "Deselect {0}", + "newFolder": "New folder" + }, "extraChecks": { "title": "Automatic checks on a submission", + "empty": "No checks added", "add": "New check", "name": "Name", "public": "Public", "bashScript": "Bash script", "dockerImage": "Docker image", + "deleteDockerImage": "Delete docker image", "timeLimit": "Time limit for execution (in seconds)", "memoryLimit": "Memory limit for execution (in MB)", - "showLog": "Making the extra logs of the docker container visible to the students" + "showLog": "Make the extra logs of the docker container visible to the students", + "showArtifact": "Make the artifacts visible to the students" } }, "submissions": { "title": "Submissions", "submit": "Submit", "course": "Course", + "allSubmissions": "Alle indieningen", + "file": "file", + "files": "files", "chooseFile": "Choose a file", "noSubmissions": "No submissions available", + "noFiles": "No files selected.", + "passed": "All tests passed", + "failed": "The submission is not passed.", + "downloadZip": "Download all files of this submission as a zip.", + "downloadLog": "Log of check {0}", + "backToSubmissions": "Back to submissions", "hoverText": { "allChecksFailed": "Structure and extra checks failed", "allChecksPassed": "All checks passed", @@ -102,15 +140,34 @@ "daysAgo": "day(s) ago", "weekAgo": "More than a week ago", "monthAgo": "More than a month ago" + }, + "feedback": { + "writeFeedback": "Write your feedback here", + "addFeedback": "Add feedback", + "dateAndAuthor": "On {0} wrote {1}:", + "edit": "Edit feedback", + "noFeedback": "No feedback given yet" + }, + "error": { + "blockedExtension": "There is a blocked extension in your solution", + "obligatedExtensionNotFound": "Obligated extension not found in your solution", + "fileDirNotFound": "File directory not found in your solution", + "dockerImageError": "Error in Docker image", + "timeLimit": "Time limit exceeded", + "memoryLimit": "Memory limit exceeded", + "checkError": "Extra tests on your submission did not pass.", + "runtimeError": "Runtime error", + "unknown": "Unknown error", + "failedStructureCheck": "Structure check failed" } }, "courses": { "create": "Create course", "edit": "Edit course", + "save": "Save course", "clone": "Clone course", "cloneAssistants": "Clone assistants:", "cloneTeachers": "Clone teachers:", - "cloneCourse": "Clone teachers:", "name": "Course name", "description": "Description", "excerpt": "Short description", @@ -121,18 +178,17 @@ "leave": "Leave", "noProjects": "No projects available for this course", "teachersAndAssistants": { - "title": "People linked to this course", + "title": "Teachers", "enroll": "Add as {0}", "leave": "Remove from course", "edit": "Edit users", "search": { "search": "Search", "faculty": "Faculty", - "role": "Role", - "no_role": "None", + "noRole": "None", "placeholder": "Search a user by name", "title": "Find users to link to this course", - "results": "{0} users found for set filters" + "results": "1 user found for set filters | {count} users found for set filters" } }, "search": { @@ -141,10 +197,7 @@ "year": "Academic year", "placeholder": "Search a course by name", "title": "Search a course", - "results": "{0} courses found for set filters" - }, - "searchByLink": { - "placeholder": "Find a course using the registration link" + "results": "1 course found for set filters | {count} courses found for set filters" }, "share": { "title": "Activate invitation link", @@ -157,15 +210,17 @@ "helpers": { "errors": { "notFound": "Not found", - "notFoundDetail": "Source not found", - "unauthorized": "unauthorized", + "notFoundDetail": "Source not found.", + "unauthorized": "Unauthorized", "unauthorizedDetail": "You are not authorized to access this resource.", - "server": "Server Error", - "serverDetail": "An error occurred on the server.", - "network": "Network Error", + "server": "Server error", + "network": "Network error", "networkDetail": "Unable to reach the server.", - "request": "request error", + "request": "Request error", "requestDetail": "An error occurred while creating the request." + }, + "success": { + "creation": "A {0} is correctly created." } } }, @@ -176,19 +231,18 @@ "createProject": "Create a new project", "searchCourse": "Search a course", "createCourse": "Create a new course", - "public": "Public", - "protected": "Protected", "csv": "Download grades as a .csv file" }, "card": { "open": "Details", "newProject": "New project", "noSubmissions": "This project does not have any submissions", - "submissions": "Submission | Submissions", - "groups": "Group | Groups", - "structureTestsSucceed": "Succeeded structure tests", - "extraTestsSucceed": "Succeeded extra tests", - "testsFail": "Failed tests", + "submissions": "Group has made a submission | Groups have made a submission", + "groups": "Participating group | Participating groups", + "structureTestsFail": "Failed structure tests", + "extraChecksFail": "Failed extra tests", + "testsSucceed": "Succeeded tests", + "successfulSubmission": "Successful submissions", "submit": "Submit" }, "list": { @@ -201,7 +255,6 @@ "teacher": "No courses found. Create a new course with the button below.", "search": "No courses found for the given search criteria." }, - "noResults": "No results.", "noIncomingProjects": "No projects with a deadline within 7 days.", "selectCourse": "Select the course for which you want to create a project:", "showPastProjects": "Projects with passed deadline" @@ -212,6 +265,21 @@ "student": "Student", "assistant": "Assistant", "teacher": "Teacher" + }, + "article": { + "admin": "The admin", + "assistant": "The assistant", + "course": "The course", + "docker": "The docker image", + "extraCheck": "The extra check", + "faculty": "The faculty", + "group": "The group", + "project": "The project", + "structureCheck": "The structure check", + "student": "The student", + "submission": "The submission", + "teacher": "The teacher", + "user": "The user" } }, "helpers": { @@ -222,6 +290,8 @@ "success": "Success", "error": "Error", "unknown": "An unknown error has occurred.", + "create": "{type} has successfully been created.", + "edit": "{type} has successfully been edited.", "courses": { "enrollment": { "success": "You have been successfully enrolled for the course '{0}'.", @@ -240,11 +310,35 @@ "success": "{0} has been successfully removed from the course.", "error": "An error occurred while removing {0} from the course." } + }, + "create": { + "success": "The course '{0}' has successfully been created.", + "error": "An error occurred while creating the course '{0}'." + } + }, + "projects": { + "create": { + "success": "The project '{0}' has successfully been created.", + "error": "An error occurred while creating the project '{0}'." + } + }, + "submissions": { + "create": { + "success": "The submission has successfully been submitted.", + "error": "An error occurred while submitting the submission." } }, "login": { "success": "You have successfully logged in.", "error": "An error occurred while logging in." + }, + "admin": { + "save": { + "error": { + "title": "Invalid save operation", + "detail": "You are trying to save an item without selecting it." + } + } } } }, @@ -256,27 +350,11 @@ "cloneCourse": "Are you sure you want to clone this coure? This will create the same course for the next academic year.", "joinCourse": "Are you sure you want to enroll in this course? This will give you access to all projects, assignments, ...", "leaveCourse": "Are you sure you want to leave this course? You will no longer have access to this course.", - "shareCourse": "By activating the invitation link, you allow students in possession of this link to enroll in this course. Please copy the generated link, only when you click on \"Activate invitation link\" will this link become active." - }, - "protectedCourses": { - "screen1": { - "title": "Obtain invitation link", - "content": "Teachers can choose to make their courses private. This means you have to ask the teacher for an invitation link, to be able to join the course." - }, - "screen2": { - "title": "Search course", - "content": "Use the invitation link to search a course. If you can't find the course, ask the teacher to share a new link." - - }, - "screen3": { - "title": "Enroll", - "content": "Enroll in the course. Now you can see all the current projects, deadlines, ..." - - } + "shareCourse": "By activating the invitation link, you allow students in possession of this link to enroll in this course. Please copy the generated link, only when you click on \"Activate invitation link\" will this link become active.", + "deleteDockerImage": "Are you sure you want to delete this docker image? This will also remove all the checks that use this image." }, "admin": { "title": "Admin", - "keyword": "Keyword", "id": "ID", "list": "List", "add": "Add", @@ -287,6 +365,7 @@ "edit": "Edit", "cancel": "Cancel", "save": "Save", + "delete": "Delete", "users": { "title": "Users", "username": "Username", @@ -294,29 +373,19 @@ "roles": "Roles" }, "user": "User", - "assistants": { - "title": "Assistants" - }, "assistant": "Assistant", - "students": { - "title": "Students" - }, "student": "Student", - "teachers": { - "title": "Teachers" - }, "teacher": "Teacher", - "catalog": "Catalog", - "docker_images": { + "dockerImages": { "title": "Docker Images", - "name_input": "Name of docker image", + "nameInput": "Name of docker image", "name": "Name", "owner": "Owner ID", - "public": "Public", - "private": "Private" + "public": "Public" }, - "none_found": "No matching data.", - "loading": "Loading data. Please wait." + "noneFound": "No matching data.", + "loading": "Loading data. Please wait.", + "safeGuard": "Are you sure?" }, "primevue": { "startsWith": "Starts with", @@ -416,6 +485,7 @@ "emptySelectionMessage": "No selected item", "emptySearchMessage": "No results found", "emptyMessage": "No available options", + "emptyFileSelect": "No file selected", "aria": { "trueLabel": "True", "falseLabel": "False", diff --git a/frontend/src/assets/lang/app/nl.json b/frontend/src/assets/lang/app/nl.json index 7864c171..4589c9be 100644 --- a/frontend/src/assets/lang/app/nl.json +++ b/frontend/src/assets/lang/app/nl.json @@ -9,14 +9,26 @@ "dashboard": "Dashboard", "calendar": "Kalender", "courses": "Vakken", - "projects": "Projecten", - "settings": "Voorkeuren", - "help": "Help" + "projects": "Projecten" }, "language": { "nl": "Nederlands", "en": "English" + }, + "notifications": { + "new": "Nieuw", + "title": "Notificaties", + "markAsRead": "Markeer alles als gelezen", + "loadMore": "Laad meer notificaties", + "noNotifications": "Geen notificaties" } + }, + "footer": { + "home": "Dashboard", + "about": "Help", + "privacy": "Cookies", + "contact": "Contacteer ons", + "rights": "Alle rechten voorbehouden" } }, "views": { @@ -25,7 +37,7 @@ "projects": "Lopende projecten" }, "verify": { - "redirect": "Je wordt zo meteen doorverwezen" + "redirect": "Je wordt zo meteen doorverwezen..." }, "login": { "title": "Inloggen", @@ -34,7 +46,7 @@ "button": "UGent login", "card": { "title": "Ypovoli", - "subtitle": "Het officieel indieningsplatform van de Universiteit Gent." + "subtitle": "Het officiële indieningsplatform van de Universiteit Gent." } }, "calendar": { @@ -43,16 +55,23 @@ }, "projects": { "all": "Alle projecten", + "backToCourse": "Terug naar het vak", "coming": "Aankomende deadlines", "deadline": "Deadline", "days": "Vandaag om {hour} | Morgen om {hour} | Over {count} dagen", - "ago": "{count} dagen geleden", + "ago": "1 dag geleden | {count} dagen geleden", + "chooseGroupMessage": "Kies een groep voor {0}", + "groupScore": "Groepsscore", + "noGroupScore": "Nog geen score", + "noGroupMembers": "Geen groepsleden", + "publishScores": "Publiceer scores", "groupName": "Groepsnaam", "groupPopulation": "Grootte", "groupStatus": "Status", "start": "Startdatum", "submissionStatus": "Indienstatus", "group": "Groep", + "groups": "Groepen", "groupSize": "Individueel | Groepen van {count} personen", "noGroups": "Geen groepen beschikbaar", "groupMembers": "Groepsleden", @@ -60,35 +79,56 @@ "joinGroup": "Kies groep", "leaveGroup": "Verlaat groep", "create": "Creëer nieuw project", - "edit": "Bewerk project", + "save": "Project opslaan", + "edit": "Project bewerken", "name": "Projectnaam", "description": "Beschrijving", - "start_date": "Start project", - "group_size": "Aantal studenten per groep (1 voor individueel project)", - "number_of_groups": "Aantal groepen (optioneel, anders #studenten / grootte groep)", - "max_score": "Maximale te behalen score", + "startDate": "Start project", + "numberStudentsGroup": "Aantal studenten per groep (1 voor individueel project)", + "numberOfGroups": "Aantal groepen (optioneel, anders #studenten / grootte groep)", + "maxScore": "Maximale te behalen score", "visibility": "Project zichtbaar maken voor studenten", "scoreVisibility": "Maak score, wanneer ingevuld, automatisch zichtbaar voor studenten", "submissionStructure": "Structuur van hoe de indiening moet gebeuren", "noStudents": "Geen studenten in deze groep", + "locked": "Gesloten", + "unlocked": "Open", + "structureChecks": { + "title": "Indieningsstructuur", + "placeholder": "Geef deze nieuwe map een naam", + "cancelSelection": "Deselecteer {0}", + "newFolder": "Nieuwe map" + }, "extraChecks": { "title": "Automatische checks op een indiening", + "empty": "Nog geen extra checks toegevoegd", "add": "Nieuwe check", "name": "Naam", "public": "Publiek", "bashScript": "Bash script", "dockerImage": "Docker image", + "deleteDockerImage": "Verwijder docker image", "timeLimit": "Tijdslimiet voor de uitvoering (in seconden)", "memoryLimit": "Geheugenlimiet voor de uitvoering (in MB)", - "showLog": "Maak de extra logs van de docker container zichtbaar voor de studenten" + "showLog": "Maak de extra logs van de docker container zichtbaar voor de studenten", + "showArtifact": "Maak de artefacten zichtbaar voor de studenten" } }, "submissions": { - "title": "Inzendingen", + "title": "Indieningen", + "allSubmissions": "Alle indieningen", "submit": "Indienen", "course": "Vak", + "file": "Bestand", + "files": "Bestanden", "chooseFile": "Kies bestand(en)", - "noSubmissions": "Geen indieningen beschikbaar", + "noSubmissions": "Geen indieningen beschikbaar.", + "noFiles": "Geen bestanden geselecteerd.", + "passed": "Alle testen zijn geslaagd.", + "failed": "De indiening is mislukt.", + "downloadZip": "Download alle bestanden als zip.", + "downloadLog": "Log bestand van check {0}", + "backToSubmissions": "Terug naar indieningen", "hoverText": { "allChecksFailed": "Structuur en extra checks gefaald", "allChecksPassed": "Alle checks geslaagd", @@ -100,11 +140,32 @@ "daysAgo": "dag(en) geleden", "weekAgo": "Meer dan een week geleden", "monthAgo": "Meer dan een maand geleden" - } + }, + "feedback": { + "addFeedback": "Feedback toevoegen", + "writeFeedback": "Schrijf hier je feedback", + "dateAndAuthor": "Geschreven op {0} door {1}", + "edit": "Bewerk feedback", + "noFeedback": "Geen feedback beschikbaar" + }, + "error":{ + "blockedExtension": "Er zit een geblokkeerde extensie in je oplossing", + "obligatedExtensionNotFound": "Verplichte extensie niet gevonden in je oplossing", + "fileDirNotFound": "Bestandsdirectory niet gevonden in je oplossing", + "DockerImageError": "Fout in Docker-afbeelding", + "timeLimit": "Tijdslimiet overschreden", + "memoryLimit": "Geheugenlimiet overschreden", + "checkError": "Extra checks in je indiening zijn niet geslaagd.", + "runtimeError": "Runtime fout", + "unknown": "Onbekende fout", + "failedStructureCheck": "Structuurcontrole mislukt" + } + }, "courses": { "create": "Creëer vak", "edit": "Bewerk vak", + "save": "Vak opslaan", "clone": "Kloon vak", "cloneAssistants": "Kloon assistenten:", "cloneTeachers": "Kloon lesgevers:", @@ -112,24 +173,23 @@ "description": "Beschrijving", "excerpt": "Korte beschrijving", "faculty": "Faculteit", - "private": "Gesloten vak (studenten kunnen enkel inschrijven via uitnodigingslink)", + "private": "Gesloten vak (studenten kunnen enkel inschrijven met een uitnodigingslink)", "year": "Academiejaar", "enroll": "Inschrijven", "leave": "Uitschrijven", "noProjects": "Geen projecten beschikbaar voor dit vak", "teachersAndAssistants": { - "title": "Lesgevers gelinkt aan dit vak", + "title": "Lesgevers", "enroll": "Voeg toe als {0}", "leave": "Verwijder uit vak", "edit": "Bewerk gebruikers", "search": { "search": "Zoeken", "faculty": "Faculteit", - "role": "Rol", - "no_role": "Geen", + "noRole": "Geen", "placeholder": "Zoek een gebruiker op naam", "title": "Zoek gebuikers om aan dit vak toe te voegen", - "results": "{0} gebruikers gevonden voor ingestelde filters" + "results": "1 gebruiker gevonden voor ingestelde filters | {count} gebruikers gevonden voor ingestelde filters" } }, "search": { @@ -138,32 +198,31 @@ "year": "Academiejaar", "placeholder": "Zoek een vak op naam", "title": "Zoek een vak", - "results": "{0} vakken gevonden voor ingestelde filters" - }, - "searchByLink": { - "placeholder": "Zoek een vak gebruik makende van een uitnodigingslink" + "results": "1 vak gevonden voor ingestelde filters | {count} vakken gevonden voor ingestelde filters" }, "share": { "title": "Activeer invitatielink", "duration": "Geldigheidsduur van link (in dagen):", "link": "Invitatielink:" } - } + } }, "composables": { "helpers": { "errors": { - "notFound": "Niet Gevonden", + "notFound": "Niet gevonden", "notFoundDetail": "Bron niet gevonden.", "unauthorized": "Onbevoegd", "unauthorizedDetail": "Je bent niet bevoegd om deze bron te bereiken.", - "server": "Server Fout", - "serverDetail": "Er vond een fout plaats op de server.", - "network": "Netwerk Fout", + "server": "Server fout", + "network": "Netwerk fout", "networkDetail": "Kan de server niet bereiken.", "request": "Fout verzoek", "requestDetail": "Een fout vond plaats tijdens het maken van het verzoek." - } + }, + "success": { + "creation": "Een {0} is correct aangemaakt." + } } }, "components": { @@ -173,32 +232,30 @@ "createProject": "Creëer nieuw project", "searchCourse": "Zoek een vak", "createCourse": "Maak een vak", - "public": "Publiek", - "protected": "Besloten", "csv": "Download punten als een .csv bestand" }, "card": { "open": "Details", "newProject": "Nieuw project", "noSubmissions": "Dit project heeft geen indieningen", - "submissions": "Indiening | Indieningen", - "groups": "Groep | Groepen", - "structureTestsSucceed": "Geslaagde structuur testen", - "extraTestsSucceed": "Geslaagde extra testen", - "testsFail": "Gefaalde testen", + "submissions": "Groep heeft al ingediend | Groepen hebben al ingediend", + "groups": "Deelnemende groep | Deelnemende groepen", + "structureTestsFail": "Gefaalde structuurtesten", + "extraChecksFail": "Gefaalde extra testen", + "testsSucceed": "Geslaagde testen", + "successfulSubmission": "Geslaagde indieningen", "submit": "Indienen" }, "list": { "noProjects": { "student": "Geen lopende projecten gevonden voor alle ingeschreven vakken. Schrijf in op een openbaar vak met de zoekfunctie, of gebruik een uitnodiginslink van een lesgever.", - "teacher": "Geen lopende projecten gevonden voor de vakken waarvoor je lesgever bent." + "teacher": "Geen lopende projecten gevonden voor de vakken waarvoor je lesgever bent. Maak een nieuw project voor een vak met onderstaande knop." }, "noCourses": { "student": "Geen vakken gevonden. Schrijf in op een openbaar vak met de zoekfunctie, of gebruik een uitnodiginslink van een lesgever.", "teacher": "Geen vakken gevonden. Maak een vak aan met onderstaande knop.", "search": "Geen vakken gevonden voor de gegeven zoekcriteria." }, - "noResults": "Geen resultaten.", "noIncomingProjects": "Geen projecten met een deadline binnen de 7 dagen.", "selectCourse": "Selecteer het vak waarvoor je een project wil maken:", "showPastProjects": "Projecten met verstreken deadline" @@ -209,6 +266,21 @@ "student": "Student", "assistant": "Assistent", "teacher": "Professor" + }, + "article": { + "admin": "De admin", + "assistant": "De assistent", + "course": "Het vak", + "docker": "De docker image", + "extraCheck": "De extra check", + "faculty": "De faculteit", + "group": "De groep", + "project": "Het project", + "structureCheck": "The structuurcheck", + "student": "De student", + "submission": "De indiening", + "teacher": "De professor", + "user": "De gebruiker" } }, "helpers": { @@ -219,6 +291,8 @@ "success": "Succes", "error": "Fout", "unknown": "Er is een onbekende fout opgetreden.", + "create": "{type} werd succesvol aangemaakt.", + "edit": "{type} werd succesvol bewerkt.", "courses": { "enrollment": { "success": "Je bent succesvol ingeschreven voor het vak '{0}'.", @@ -237,11 +311,35 @@ "success": "{0} werd succesvol verwijderd uit het vak.", "error": "Er is een fout opgetreden tijdens het verwijderen van {0} uit het vak." } + }, + "create": { + "success": "Het vak '{0}' werd succesvol aangemaakt.", + "error": "Er is een fout opgetreden tijdens het aanmaken van het vak '{0}'." + } + }, + "projects": { + "create": { + "success": "Het project '{0}' werd succesvol aangemaakt.", + "error": "Er is een fout opgetreden tijdens het aanmaken van het project '{0}'." + } + }, + "submissions": { + "create": { + "success": "De indiening is succesvol ingediend.", + "error": "Er is een fout opgetreden tijdens het indienen van de indiening." } }, "login": { "success": "Je bent succesvol ingelogd.", "error": "Er is een fout opgetreden tijdens het inloggen." + }, + "admin": { + "save": { + "error": { + "title": "Ongeldige opsla bewerking", + "detail": "U probeert een item op te slaan zonder dit te selecteren." + } + } } } }, @@ -253,26 +351,11 @@ "cloneCourse": "Ben je zeker dat je dit vak wil klonen? Dit zal hetzelfde vak aanmaken voor het volgende academiejaar.", "joinCourse": "Ben je zeker dat je jezelf wil inschrijven voor dit vak? Dit zal je toegang geven tot alle projecten, opdrachten, ...", "leaveCourse": "Ben je zeker dat je dit vak wil verlaten? Je zal geen toegang meer hebben tot dit vak.", - "shareCourse": "Door het activeren van de invitatielink staat u studenten in bezit van deze link toe zich in te schrijven voor dit vak. Gelieve de gegenereerde link te kopiëren, pas wanneer u op \"Activeer invitatielink\" klikt zal deze link actief worden." - }, - "protectedCourses": { - "screen1": { - "title": "Bemachtigen link", - "content": "Professoren kunnen kiezen om hun vakken niet publiek te maken. Vraag de prof om een invitatielink te delen om te kunnen toetreden tot het vak." - }, - "screen2": { - "title": "Vak zoeken", - "content": "Gebruik de link om het vak te zoeken. Als je het vak niet kan vinden, kan je de prof vragen om een nieuwe link te delen." - - }, - "screen3": { - "title": "Inschrijven", - "content": "Schrijf je in voor het vak. Je kan nu een overzicht raadplegen van alle lopende projecten, deadlines, ..." - } + "shareCourse": "Door het activeren van de invitatielink staat u studenten in bezit van deze link toe zich in te schrijven voor dit vak. Gelieve de gegenereerde link te kopiëren, pas wanneer u op \"Activeer invitatielink\" klikt zal deze link actief worden.", + "deleteDockerImage": "Ben je zeker dat je deze docker image wil verwijderen? Dit zal alle checks die deze image gebruiken ook verwijderen." }, "admin": { "title": "Beheerder", - "keyword": "Trefwoord", "id": "ID", "list": "Lijst", "add": "Voeg toe", @@ -283,6 +366,7 @@ "edit": "Bewerken", "cancel": "Annuleer", "save": "Sla op", + "delete": "Verwijder", "users": { "title": "Gebruikers", "username": "Gebruikersnaam", @@ -290,29 +374,19 @@ "roles": "Functies" }, "user": "Gebruiker", - "assistants": { - "title": "Assistenten" - }, "assistant": "Assistent", - "students": { - "title": "Studenten" - }, "student": "Student", - "teachers": { - "title": "Proffen" - }, "teacher": "Prof", - "catalog": "Catalogus", - "docker_images": { + "dockerImages": { "title": "Docker Images", - "name_input": "Naam van docker image", + "nameInput": "Naam van docker image", "name": "Naam", "owner": "Eigenaar ID", - "public": "Publiek", - "private": "Privaat" + "public": "Publiek" }, - "none_found": "Geen overeenkomende data gevonden.", - "loading": "Aan het laden. Wacht een momentje aub." + "noneFound": "Geen overeenkomende data gevonden.", + "loading": "Aan het laden. Wacht even aub.", + "safeGuard": "Bent u het zeker?" }, "primevue": { "accept": "Ja", @@ -360,10 +434,11 @@ "vrij", "zat" ], - "emptyFilterMessage": "Geen resultaten gevonden", - "emptyMessage": "Geen resultaten gevonden", - "emptySearchMessage": "Geen resultaten gevonden", + "emptyFilterMessage": "Geen resultaten gevonden \uD83D\uDE2D", + "emptyMessage": "Geen resultaten gevonden \uD83D\uDE2D", + "emptySearchMessage": "Geen resultaten gevonden \uD83D\uDE2D", "emptySelectionMessage": "Geen optie geselecteerd", + "emptyFileSelect": "Geen bestand geselecteerd", "endsWith": "Eindigt met", "equals": "Is gelijk aan", "fileSizeTypes": [ diff --git a/frontend/src/assets/lang/info/en.json b/frontend/src/assets/lang/info/en.json index f376b91d..9e26dfee 100644 --- a/frontend/src/assets/lang/info/en.json +++ b/frontend/src/assets/lang/info/en.json @@ -1,8 +1 @@ -{ - "header": { - "student": "Student", - "teacher": "Teacher", - "assistant": "Assistant", - "admin": "Admin" - } -} \ No newline at end of file +{} \ No newline at end of file diff --git a/frontend/src/assets/scss/theme/base/components/overlay/_tooltip.scss b/frontend/src/assets/scss/theme/base/components/overlay/_tooltip.scss index 567e0252..a87c9c78 100644 --- a/frontend/src/assets/scss/theme/base/components/overlay/_tooltip.scss +++ b/frontend/src/assets/scss/theme/base/components/overlay/_tooltip.scss @@ -57,7 +57,7 @@ // theme .p-tooltip { .p-tooltip-text { - background: $primaryColor; + background: $secondaryTextColor; color: $tooltipTextColor; padding: $tooltipPadding; box-shadow: $inputOverlayShadow; @@ -66,25 +66,25 @@ &.p-tooltip-right { .p-tooltip-arrow { - border-right-color: $primaryColor; + border-right-color: $secondaryTextColor; } } &.p-tooltip-left { .p-tooltip-arrow { - border-left-color: $primaryColor; + border-left-color: $secondaryTextColor; } } &.p-tooltip-top { .p-tooltip-arrow { - border-top-color: $primaryColor; + border-top-color: $secondaryTextColor; } } &.p-tooltip-bottom { .p-tooltip-arrow { - border-bottom-color: $primaryColor; + border-bottom-color: $secondaryTextColor; } } } diff --git a/frontend/src/components/Loading.vue b/frontend/src/components/Loading.vue new file mode 100644 index 00000000..272c1aa0 --- /dev/null +++ b/frontend/src/components/Loading.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/frontend/src/components/admin/LazyDataTable.vue b/frontend/src/components/admin/LazyDataTable.vue index 9a1e2a32..49b91e89 100644 --- a/frontend/src/components/admin/LazyDataTable.vue +++ b/frontend/src/components/admin/LazyDataTable.vue @@ -8,14 +8,27 @@ import { type Filter } from '@/types/filter/Filter.ts'; import Column from 'primevue/column'; /* Properties */ -const props = defineProps<{ - pagination: PaginatorResponse | null; - entities: any[] | null; // list containing all the entities displayed by data table after executing get method - get: () => Promise; // get method for backend - search: (filters: Filter, page: number, pageSize: number) => Promise; - filter: Filter; - onFilter: (callback: () => Promise, debounce?: number | undefined, immediate?: boolean | undefined) => void; -}>(); +const props = withDefaults( + defineProps<{ + pagination: PaginatorResponse | null; + entities: any[] | null; // list containing all the entities displayed by data table after executing get method + get: () => Promise; // get method for backend + search: (filters: Filter, page: number, pageSize: number) => Promise; + filter: Filter; + onFilter: ( + callback: () => Promise, + debounce?: number | undefined, + immediate?: boolean | undefined, + ) => void; + select?: boolean; + }>(), + { + select: false, + }, +); + +/* Emits */ +const emit = defineEmits(['select']); /* Injections */ const { t } = useI18n(); @@ -59,17 +72,21 @@ const onSelectAllChange = (event: DataTableSelectAllChangeEvent): void => { props.get().then(() => { selectAll.value = true; selected.value = props.entities; + emit('select', selected.value); }); } else { selectAll.value = false; selected.value = []; + emit('select', selected.value); } }; const onRowSelect = (): void => { selectAll.value = selected.value?.length === (props.pagination?.count ?? 0); + emit('select', selected.value); }; const onRowUnselect = (): void => { selectAll.value = false; + emit('select', selected.value); }; defineExpose({ fetch }); @@ -101,7 +118,7 @@ defineExpose({ fetch }); - + diff --git a/frontend/src/components/courses/CourseDetailCard.vue b/frontend/src/components/courses/CourseDetailCard.vue index 024c5d8b..ce92fc59 100644 --- a/frontend/src/components/courses/CourseDetailCard.vue +++ b/frontend/src/components/courses/CourseDetailCard.vue @@ -14,27 +14,41 @@ defineProps<{ /* Composable injections */ const { t } = useI18n(); const { getRandomImport } = useGlob(import.meta.glob('@/assets/img/placeholders/*.png', { eager: true })); +const { getImport } = useGlob(import.meta.glob('@/assets/img/faculties/*.png', { eager: true }));