From dfc4f808e6f045c92dc8f25a862e45132a68f89b Mon Sep 17 00:00:00 2001 From: Yassine Souissi <74144843+yassinsws@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:26:39 +0100 Subject: [PATCH 01/53] Iris: Add links to citations for lecture questions (#9019) --- .../aet/artemis/iris/service/pyris/PyrisWebhookService.java | 5 +++-- .../lectureingestionwebhook/PyrisLectureUnitWebhookDTO.java | 3 ++- .../tum/cit/aet/artemis/iris/PyrisConnectorServiceTest.java | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisWebhookService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisWebhookService.java index 2138b8789b0b..395932a7157a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisWebhookService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/PyrisWebhookService.java @@ -94,15 +94,16 @@ private PyrisLectureUnitWebhookDTO processAttachmentForUpdate(AttachmentUnit att String courseTitle = attachmentUnit.getLecture().getCourse().getTitle(); String courseDescription = attachmentUnit.getLecture().getCourse().getDescription() == null ? "" : attachmentUnit.getLecture().getCourse().getDescription(); String base64EncodedPdf = attachmentToBase64(attachmentUnit); + String lectureUnitLink = artemisBaseUrl + attachmentUnit.getAttachment().getLink(); lectureUnitRepository.save(attachmentUnit); - return new PyrisLectureUnitWebhookDTO(base64EncodedPdf, lectureUnitId, lectureUnitName, lectureId, lectureTitle, courseId, courseTitle, courseDescription); + return new PyrisLectureUnitWebhookDTO(base64EncodedPdf, lectureUnitId, lectureUnitName, lectureId, lectureTitle, courseId, courseTitle, courseDescription, lectureUnitLink); } private PyrisLectureUnitWebhookDTO processAttachmentForDeletion(AttachmentUnit attachmentUnit) { Long lectureUnitId = attachmentUnit.getId(); Long lectureId = attachmentUnit.getLecture().getId(); Long courseId = attachmentUnit.getLecture().getCourse().getId(); - return new PyrisLectureUnitWebhookDTO("", lectureUnitId, "", lectureId, "", courseId, "", ""); + return new PyrisLectureUnitWebhookDTO("", lectureUnitId, "", lectureId, "", courseId, "", "", ""); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisLectureUnitWebhookDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisLectureUnitWebhookDTO.java index b2f2cde1019d..90985390a40a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisLectureUnitWebhookDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/pyris/dto/lectureingestionwebhook/PyrisLectureUnitWebhookDTO.java @@ -8,6 +8,7 @@ * providing necessary details such as lecture and course identifiers, names, and descriptions. */ @JsonInclude(JsonInclude.Include.NON_EMPTY) + public record PyrisLectureUnitWebhookDTO(String pdfFile, long lectureUnitId, String lectureUnitName, long lectureId, String lectureName, long courseId, String courseName, - String courseDescription) { + String courseDescription, String lectureUnitLink) { } diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisConnectorServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisConnectorServiceTest.java index 67f18be698ef..6d0b38315e04 100644 --- a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisConnectorServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisConnectorServiceTest.java @@ -52,7 +52,7 @@ void testExceptionV2(int httpStatus, Class exceptionClass) { void testExceptionIngestionV2(int httpStatus, Class exceptionClass) { irisRequestMockProvider.mockIngestionWebhookRunError(httpStatus); PyrisLectureUnitWebhookDTO pyrisLectureUnitWebhookDTO = new PyrisLectureUnitWebhookDTO("example.pdf", 123L, "Lecture Unit Name", 456L, "Lecture Name", 789L, "Course Name", - "Course Description"); + "Course Description", "/example/test.pdf"); PyrisWebhookLectureIngestionExecutionDTO executionDTO = new PyrisWebhookLectureIngestionExecutionDTO(pyrisLectureUnitWebhookDTO, null, List.of()); assertThatThrownBy(() -> pyrisConnectorService.executeLectureAddtionWebhook("fullIngestion", executionDTO)).isInstanceOf(exceptionClass); } From 8398a2793b00ab1e71518da933e789dbdd02c7c6 Mon Sep 17 00:00:00 2001 From: Dennis Jandow <95364200+janthoXO@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:29:13 +0100 Subject: [PATCH 02/53] Development: Swap Test Names for Bearer auth (#9982) --- .../tum/cit/aet/artemis/core/security/jwt/JWTFilterTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterTest.java b/src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterTest.java index b50a63de8202..6ce42eb21d7f 100644 --- a/src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterTest.java @@ -62,7 +62,7 @@ void testJWTFilterCookie() throws Exception { } @Test - void testJWTFilterBearer() throws Exception { + void testJWTFilterCookieAndBearer() throws Exception { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken("test-user", "test-password", Collections.singletonList(new SimpleGrantedAuthority(Role.STUDENT.getAuthority()))); @@ -79,7 +79,7 @@ void testJWTFilterBearer() throws Exception { } @Test - void testJWTFilterCookieAndBearer() throws Exception { + void testJWTFilterBearer() throws Exception { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken("test-user", "test-password", Collections.singletonList(new SimpleGrantedAuthority(Role.STUDENT.getAuthority()))); From 3f38127361dae6258b965e5490fad6e1be4b6573 Mon Sep 17 00:00:00 2001 From: Simon Entholzer <33342534+SimonEntholzer@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:45:19 +0100 Subject: [PATCH 03/53] Programming exercises: Only replace existing files when populating build plan repositories (#9968) --- .../buildagent/service/BuildJobContainerService.java | 11 +++-------- src/main/webapp/i18n/de/programmingExercise.json | 2 +- src/main/webapp/i18n/en/programmingExercise.json | 2 +- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java index 3c7ff12881cd..f4d1eae004e5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/BuildJobContainerService.java @@ -342,16 +342,11 @@ private void createScriptFile(String buildJobContainerId) { private void addAndPrepareDirectoryAndReplaceContent(String containerId, Path repositoryPath, String newDirectoryName) { copyToContainer(repositoryPath.toString(), containerId); addDirectory(containerId, newDirectoryName, true); - removeDirectoryAndFiles(containerId, newDirectoryName); - renameDirectoryOrFile(containerId, LOCALCI_WORKING_DIRECTORY + "/" + repositoryPath.getFileName().toString(), newDirectoryName); + insertRepositoryFiles(containerId, LOCALCI_WORKING_DIRECTORY + "/" + repositoryPath.getFileName().toString(), newDirectoryName); } - private void removeDirectoryAndFiles(String containerId, String newName) { - executeDockerCommand(containerId, null, false, false, true, "rm", "-rf", newName); - } - - private void renameDirectoryOrFile(String containerId, String oldName, String newName) { - executeDockerCommand(containerId, null, false, false, true, "mv", oldName, newName); + private void insertRepositoryFiles(String containerId, String oldName, String newName) { + executeDockerCommand(containerId, null, false, false, true, "cp", "-r", oldName + (oldName.endsWith("/") ? "." : "/."), newName); } private void addDirectory(String containerId, String directoryName, boolean createParentsIfNecessary) { diff --git a/src/main/webapp/i18n/de/programmingExercise.json b/src/main/webapp/i18n/de/programmingExercise.json index 109036a5f9d6..850fc2330e0d 100644 --- a/src/main/webapp/i18n/de/programmingExercise.json +++ b/src/main/webapp/i18n/de/programmingExercise.json @@ -578,7 +578,7 @@ "auxiliaryRepository": { "error": "Es gibt ein Problem mit den Hilfs-Repositories!", "addAuxiliaryRepository": "Hilfs-Repository anlegen", - "usageDescription": "Du kannst Hilfsrepositorien verwenden, um zusätzlichen Code bereitzustellen, den die Studierenden nicht sehen oder ändern können. Der zusätzliche Code wird im angegebenen Checkout-Verzeichnis eingefügt, bevor der Build erstellt wird. Der eingefügte Code überschreibt alles, was sich an der durch das Checkout-Verzeichnis angegebenen Stelle befindet. Wenn du die Dateien der Studierenden nur teilweise überschreiben willst, muss das Build-Skript angepasst werden.", + "usageDescription": "Du kannst Hilfsrepositorien verwenden, um zusätzlichen Code bereitzustellen, den die Studierenden nicht sehen oder ändern können. Der zusätzliche Code wird im angegebenen Checkout-Verzeichnis eingefügt, bevor der Build erstellt wird. Die eingefügten Dateien überschreiben alle Dateien im Checkout-Verzeichnis, die den gleichen Namen haben. Dateien, die nicht überschrieben werden, bleiben erhalten.", "repositoryName": "Name des Repositorys", "checkoutDirectory": "Checkout-Verzeichnis", "description": "Beschreibung", diff --git a/src/main/webapp/i18n/en/programmingExercise.json b/src/main/webapp/i18n/en/programmingExercise.json index 4835223da235..182a28afc520 100644 --- a/src/main/webapp/i18n/en/programmingExercise.json +++ b/src/main/webapp/i18n/en/programmingExercise.json @@ -578,7 +578,7 @@ "auxiliaryRepository": { "error": "There is a problem with the auxiliary repository.", "addAuxiliaryRepository": "Add Auxiliary Repository", - "usageDescription": "You can use auxiliary repositories to provide additional code that students cannot see or modify. The additional code is inserted into the specified checkout directory before the submission is built. The inserted code overwrites everything which lies at the location specified by the checkout directory. If you only need to overwrite student files partially, you need to adapt the build script.", + "usageDescription": "You can use auxiliary repositories to provide additional code that students cannot see or modify. The additional code is inserted into the specified checkout directory before the submission is built. The additional code is inserted in the specified checkout directory before the build is created. The inserted files overwrite all files in the checkout directory which have the same name. Files that are not overwritten are retained.", "repositoryName": "Repository Name", "checkoutDirectory": "Checkout Directory", "description": "Description", From d35c6f903a01249abe2b5feaad9bc1907dac246c Mon Sep 17 00:00:00 2001 From: Timor Morrien Date: Tue, 10 Dec 2024 17:48:30 +0100 Subject: [PATCH 04/53] Quiz exercises: Fix an issue when automatically evaluating a live quiz (#9988) --- .../aet/artemis/quiz/service/QuizSubmissionService.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizSubmissionService.java b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizSubmissionService.java index 481648fb9636..2018639aec81 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizSubmissionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizSubmissionService.java @@ -183,7 +183,13 @@ else if (quizExercise.isQuizEnded()) { // avoid LazyInitializationException participation.setResults(Set.of(result)); + var course = quizExercise.getCourseViaExerciseGroupOrCourseMember(); sendQuizResultToUser(quizExerciseId, participation); + if (course != null) { + // This is required, as sendQuizResultToUser removes the course from the quizExercise + // TODO: This should be fixed by using DTOs in the future + quizExercise.setCourse(course); + } }); quizStatisticService.recalculateStatistics(quizExercise); // notify users via websocket about new results for the statistics, filter out solution information @@ -198,7 +204,7 @@ private void sendQuizResultToUser(long quizExerciseId, StudentParticipation part websocketMessagingService.sendMessageToUser(user, "/topic/exercise/" + quizExerciseId + "/participation", participation); } - // Use a DTO instead of removing data from the entity + // TODO: Use a DTO instead of removing data from the entity @Deprecated private void removeUnnecessaryObjectsBeforeSendingToClient(StudentParticipation participation) { if (participation.getExercise() != null) { From 47da721215bf5a11c1cc228efec39e2cd3bcd703 Mon Sep 17 00:00:00 2001 From: Simon Entholzer <33342534+SimonEntholzer@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:53:32 +0100 Subject: [PATCH 05/53] Development: Improve and simplify local playwright setup (#9796) --- docs/dev/playwright.rst | 47 ++++++++++++++----- ...mportUsers.spec.ts => importUsers.spec.ts} | 0 src/test/playwright/package.json | 2 + src/test/playwright/playwright.env | 5 +- supporting_scripts/playwright/README.md | 25 ++++++++++ .../playwright/runArtemisInDocker_linux.sh | 20 ++++++++ .../playwright/runArtemisInDocker_macOS.sh | 26 ++++++++++ supporting_scripts/playwright/setupUsers.sh | 19 ++++++++ .../playwright/startPlaywright.sh | 16 +++++++ 9 files changed, 144 insertions(+), 16 deletions(-) rename src/test/playwright/init/{ImportUsers.spec.ts => importUsers.spec.ts} (100%) create mode 100644 supporting_scripts/playwright/README.md create mode 100755 supporting_scripts/playwright/runArtemisInDocker_linux.sh create mode 100755 supporting_scripts/playwright/runArtemisInDocker_macOS.sh create mode 100755 supporting_scripts/playwright/setupUsers.sh create mode 100755 supporting_scripts/playwright/startPlaywright.sh diff --git a/docs/dev/playwright.rst b/docs/dev/playwright.rst index af16556675b5..c4edd85d5399 100644 --- a/docs/dev/playwright.rst +++ b/docs/dev/playwright.rst @@ -7,6 +7,18 @@ Set up Playwright locally To run the tests locally, developers need to set up Playwright on their machines. End-to-end tests test entire workflows; therefore, they require the whole Artemis setup - database, client, and server to be running. Playwright tests rely on the Playwright Node.js library, browser binaries, and some helper packages. +To run playwright tests locally, you need to start the Artemis server and client, have the correct users set up and install and run playwright. +This setup should be used for debugging, and creating new tests for your code, but needs intellij to work, and relies on fully setting up your local Artemis instance +following :ref:`the server setup guide`. + + +For a quick test setup with only three steps, you can use the scripts provided in `supportingScripts/playwright`. +The README explains what you need to do. +It sets up Artemis inside a dockerized environment, creates users and directly starts playwright. The main drawback with this setup is, that you cannot +easily change the version of Artemis itself. + + +If you want to manually install playwright, you can follow these steps: 1. Install dependencies: @@ -29,8 +41,8 @@ Playwright tests rely on the Playwright Node.js library, browser binaries, and s .. code-block:: text - PLAYWRIGHT_USERNAME_TEMPLATE=artemis_test_user_USERID - PLAYWRIGHT_PASSWORD_TEMPLATE=artemis_test_user_USERID + PLAYWRIGHT_USERNAME_TEMPLATE=artemis_test_user_ + PLAYWRIGHT_PASSWORD_TEMPLATE=artemis_test_user_ ADMIN_USERNAME=artemis_admin ADMIN_PASSWORD=artemis_admin ALLOW_GROUP_CUSTOMIZATION=true @@ -38,31 +50,40 @@ Playwright tests rely on the Playwright Node.js library, browser binaries, and s TUTOR_GROUP_NAME=tutors EDITOR_GROUP_NAME=editors INSTRUCTOR_GROUP_NAME=instructors - CREATE_USERS=true BASE_URL=http://localhost:9000 EXERCISE_REPO_DIRECTORY=test-exercise-repos + FAST_TEST_TIMEOUT_SECONDS=45 + SLOW_TEST_TIMEOUT_SECONDS=180 + Make sure ``BASE_URL`` matches your Artemis client URL and ``ADMIN_USERNAME`` and ``ADMIN_PASSWORD`` match your Artemis admin user credentials. 3. Configure test users - Playwright tests require users with different roles to simulate concurrent user interactions. You can configure - user IDs and check their corresponding user roles in the ``src/test/playwright/support/users.ts`` file. Usernames - are defined automatically by replacing the ``USERID`` part in ``PLAYWRIGHT_USERNAME_TEMPLATE`` with the - corresponding user ID. If users with such usernames do not exist, set ``CREATE_USERS`` to ``true`` on the - ``playwright.env`` file for users to be created during the setup stage. If users with the same usernames but - different user roles already exist, change the user IDs to different values to ensure that new users are created - with roles defined in the configuration. + Playwright tests require users with different roles to simulate concurrent user interactions. If you already + have generated test users, you can skip this step. Generate users with the help of the user creation scripts under the + `supportingScripts/playwright` folder: + + .. code-block:: bash + + setupUsers.sh + + You can configure user IDs and check their corresponding user roles in the ``src/test/playwright/support/users.ts`` file. + Usernames are defined automatically by appending the userId to the ``PLAYWRIGHT_USERNAME_TEMPLATE``. + At the moment it is discouraged to change the template string, as the user creation script does not support other names yet. 4. Setup Playwright package and its browser binaries: - Install Playwright browser binaries, set up the environment to ensure Playwright can locate these binaries, and - create test users (if creating users is enabled in the configuration) with the following command: + Install Playwright browser binaries, set up the environment to ensure Playwright can locate these binaries. + On some operating systems this might not work, and playwright needs to be manually installed via a package manager. .. code-block:: bash - npm run playwright:setup + npm run playwright:setup-local + npm run playwright:init + + 5. Open Playwright UI diff --git a/src/test/playwright/init/ImportUsers.spec.ts b/src/test/playwright/init/importUsers.spec.ts similarity index 100% rename from src/test/playwright/init/ImportUsers.spec.ts rename to src/test/playwright/init/importUsers.spec.ts diff --git a/src/test/playwright/package.json b/src/test/playwright/package.json index eeac0766746c..5e37ba3caba9 100644 --- a/src/test/playwright/package.json +++ b/src/test/playwright/package.json @@ -20,6 +20,8 @@ "playwright:test:sequential": "cross-env PLAYWRIGHT_JUNIT_OUTPUT_NAME=./test-reports/results-sequential.xml playwright test e2e --project=sequential-tests --workers 1", "playwright:open": "playwright test e2e --ui", "playwright:setup": "npx playwright install --with-deps chromium && playwright test init", + "playwright:setup-local": "npx playwright install --with-deps chromium", + "playwright:init": "playwright test init", "merge-reports": "junit-merge ./test-reports/results-parallel.xml ./test-reports/results-sequential.xml -o ./test-reports/results.xml", "update": "ncu -i --format group" } diff --git a/src/test/playwright/playwright.env b/src/test/playwright/playwright.env index b60373794a3b..b94fac3f3589 100644 --- a/src/test/playwright/playwright.env +++ b/src/test/playwright/playwright.env @@ -1,5 +1,5 @@ -PLAYWRIGHT_USERNAME_TEMPLATE=artemis_test_user_USERID -PLAYWRIGHT_PASSWORD_TEMPLATE=artemis_test_user_USERID +PLAYWRIGHT_USERNAME_TEMPLATE=artemis_test_user_ +PLAYWRIGHT_PASSWORD_TEMPLATE=artemis_test_user_ ADMIN_USERNAME=artemis_admin ADMIN_PASSWORD=artemis_admin ALLOW_GROUP_CUSTOMIZATION=true @@ -7,7 +7,6 @@ STUDENT_GROUP_NAME=students TUTOR_GROUP_NAME=tutors EDITOR_GROUP_NAME=editors INSTRUCTOR_GROUP_NAME=instructors -CREATE_USERS=true BASE_URL=http://localhost:9000 EXERCISE_REPO_DIRECTORY=test-exercise-repos FAST_TEST_TIMEOUT_SECONDS=45 diff --git a/supporting_scripts/playwright/README.md b/supporting_scripts/playwright/README.md new file mode 100644 index 000000000000..3358943720f6 --- /dev/null +++ b/supporting_scripts/playwright/README.md @@ -0,0 +1,25 @@ +# Easy Artemis set up and running playwright locally + +Running playwright locally involves three steps: +1. Run an Artemis application instance, with client and server. +2. If no users have been set up, set up users. +3. Install and run playwright. + +## 1. Start Artemis + +To start Artemis, depending on your OS, either run `runArtemisInDocker_macOS.sh` or `runArtemisInDocker_linux.sh`. +This will set up the database, start Artemis inside a docker container, and start the client via npm. +After this step, you are be able to access Artemis locally as you usually would be. +Note that you need to run the scripts in step 2 and 3 in another shell, as the client needs to keep running. +In case you stop the client, you can simply re-run it at the root of the Artemis project with `npm run start`. + +## 2. Setup users + +Playwright needs users for it's tests. If you do not have users set up, you can simply do so by running: +`setupUsers.sh` +This will create 20 test users. + +## 3. Setup Playwright and run Playwright in UI-mode + +Simply run: `startPlaywright.sh`. This will install the necessary dependencies for playwright and start it in UI mode. +If you already have playwright installed, you can also start playwright directly from the `src/test/playwright` directory with `npm run playwright:open`. diff --git a/supporting_scripts/playwright/runArtemisInDocker_linux.sh b/supporting_scripts/playwright/runArtemisInDocker_linux.sh new file mode 100755 index 000000000000..c979ea2d32b1 --- /dev/null +++ b/supporting_scripts/playwright/runArtemisInDocker_linux.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +set -e + +artemis_path="$(readlink -f "$(dirname $0)/../..")" + +cd "$artemis_path/docker" + +echo "Updating docker group ID in the docker compose file" +sed -i "s/999/$(getent group docker | cut -d: -f3)/g" artemis-dev-local-vc-local-ci-mysql.yml + +docker compose -f artemis-dev-local-vc-local-ci-mysql.yml up -d +echo "Finished docker compose" + +cd "$artemis_path" + +echo "Installing Artemis npm dependencies and start Artemis client" + +npm install +npm run start diff --git a/supporting_scripts/playwright/runArtemisInDocker_macOS.sh b/supporting_scripts/playwright/runArtemisInDocker_macOS.sh new file mode 100755 index 000000000000..ab8dc424e740 --- /dev/null +++ b/supporting_scripts/playwright/runArtemisInDocker_macOS.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +set -e + +artemis_path="$(readlink -f "$(dirname $0)/../..")" + +cd "$artemis_path/docker" +open -a Docker + +echo "Updating docker group ID in the docker compose file" +PRIMARY_GROUP_ID=$(dscl . -read /Groups/docker PrimaryGroupID | awk '{print $2}') +if [ -n "$PRIMARY_GROUP_ID" ]; then + sed -i '' "s/999/$PRIMARY_GROUP_ID/g" artemis-dev-local-vc-local-ci-mysql.yml +else + echo "PrimaryGroupID not found, skipping replacement" +fi + +docker compose -f artemis-dev-local-vc-local-ci-mysql.yml up -d +echo "Finished docker compose" + +cd "$artemis_path" + +echo "Installing Artemis npm dependencies and start Artemis client" + +npm install +npm run start diff --git a/supporting_scripts/playwright/setupUsers.sh b/supporting_scripts/playwright/setupUsers.sh new file mode 100755 index 000000000000..19677c16d28f --- /dev/null +++ b/supporting_scripts/playwright/setupUsers.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +# We use the supporting scripts to create users + +set -e +artemis_path="$(readlink -f "$(dirname $0)/../..")" + +cd "$artemis_path/supporting_scripts" + +if [ ! -d "venv" ]; then + python -m venv venv +fi + +source venv/bin/activate + +cd "$artemis_path/supporting_scripts/course-scripts/quick-course-setup" + +python3 -m pip install -r requirements.txt +python3 create_users.py diff --git a/supporting_scripts/playwright/startPlaywright.sh b/supporting_scripts/playwright/startPlaywright.sh new file mode 100755 index 000000000000..412b88919978 --- /dev/null +++ b/supporting_scripts/playwright/startPlaywright.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +set -e + +artemis_path="$(readlink -f "$(dirname $0)/../..")" + +echo "Installing Playwright and dependencies" + +cd "$artemis_path/src/test/playwright" + +npm install + +npm run playwright:setup-local || true + +echo "Starting Playwright in UI mode" +npm run playwright:open From 3744a6d1e143f0dfa5b7489cbc8b66ba67639bfc Mon Sep 17 00:00:00 2001 From: Tim Cremer <65229601+cremertim@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:54:36 +0100 Subject: [PATCH 06/53] Communication: Migrate FAQ feature to new client guidelines (#9902) --- src/main/webapp/app/faq/faq.routes.ts | 4 ++-- src/main/webapp/app/faq/faq.service.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/webapp/app/faq/faq.routes.ts b/src/main/webapp/app/faq/faq.routes.ts index 0b756a8c28ff..88b75dbdb00f 100644 --- a/src/main/webapp/app/faq/faq.routes.ts +++ b/src/main/webapp/app/faq/faq.routes.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { HttpResponse } from '@angular/common/http'; import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; import { Observable, of } from 'rxjs'; @@ -8,7 +8,7 @@ import { Faq } from 'app/entities/faq.model'; @Injectable({ providedIn: 'root' }) export class FaqResolve implements Resolve { - constructor(private faqService: FaqService) {} + private faqService = inject(FaqService); resolve(route: ActivatedRouteSnapshot): Observable { const faqId = route.params['faqId']; diff --git a/src/main/webapp/app/faq/faq.service.ts b/src/main/webapp/app/faq/faq.service.ts index 8542fd814c49..2b8e8a5dd546 100644 --- a/src/main/webapp/app/faq/faq.service.ts +++ b/src/main/webapp/app/faq/faq.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -12,7 +12,7 @@ type EntityArrayResponseType = HttpResponse; export class FaqService { public resourceUrl = 'api/courses'; - constructor(protected http: HttpClient) {} + private http = inject(HttpClient); create(courseId: number, faq: Faq): Observable { const copy = FaqService.convertFaqFromClient(faq); From 32093a29dfafad13d25ad200cbcc00e1d2dfd3e4 Mon Sep 17 00:00:00 2001 From: Mohamed Bilel Besrour <58034472+BBesrour@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:55:51 +0100 Subject: [PATCH 07/53] Development: Migrate build agents components (#9923) --- src/main/webapp/app/admin/admin.module.ts | 4 ++-- .../build-agent-details.component.ts | 22 ++++++++++-------- .../build-agent-summary.component.ts | 23 +++++++++++-------- .../build-agents/build-agents.service.ts | 4 ++-- .../build-agent-details.component.spec.ts | 8 +++---- .../build-agent-summary.component.spec.ts | 8 +++---- 6 files changed, 36 insertions(+), 33 deletions(-) diff --git a/src/main/webapp/app/admin/admin.module.ts b/src/main/webapp/app/admin/admin.module.ts index 28283dd58512..1d5d5e8314ed 100644 --- a/src/main/webapp/app/admin/admin.module.ts +++ b/src/main/webapp/app/admin/admin.module.ts @@ -75,6 +75,8 @@ const ENTITY_STATES = [...adminState]; StandardizedCompetencyDetailComponent, DeleteUsersButtonComponent, ProfilePictureComponent, + BuildAgentSummaryComponent, + BuildAgentDetailsComponent, ], declarations: [ AuditsComponent, @@ -97,8 +99,6 @@ const ENTITY_STATES = [...adminState]; OrganizationManagementUpdateComponent, LtiConfigurationComponent, EditLtiConfigurationComponent, - BuildAgentSummaryComponent, - BuildAgentDetailsComponent, StandardizedCompetencyEditComponent, KnowledgeAreaEditComponent, StandardizedCompetencyManagementComponent, diff --git a/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts b/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts index 1408b83c2495..3f1b1628d951 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts +++ b/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts @@ -1,4 +1,4 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; import { BuildAgentInformation } from 'app/entities/programming/build-agent-information.model'; import { BuildAgentsService } from 'app/localci/build-agents/build-agents.service'; import { Subscription } from 'rxjs'; @@ -9,13 +9,25 @@ import { ActivatedRoute } from '@angular/router'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { BuildQueueService } from 'app/localci/build-queue/build-queue.service'; import { AlertService, AlertType } from 'app/core/util/alert.service'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { ArtemisDataTableModule } from 'app/shared/data-table/data-table.module'; +import { NgxDatatableModule } from '@siemens/ngx-datatable'; +import { SubmissionResultStatusModule } from 'app/overview/submission-result-status.module'; @Component({ selector: 'jhi-build-agent-details', templateUrl: './build-agent-details.component.html', styleUrl: './build-agent-details.component.scss', + standalone: true, + imports: [ArtemisSharedModule, NgxDatatableModule, ArtemisDataTableModule, SubmissionResultStatusModule], }) export class BuildAgentDetailsComponent implements OnInit, OnDestroy { + private readonly websocketService = inject(JhiWebsocketService); + private readonly buildAgentsService = inject(BuildAgentsService); + private readonly route = inject(ActivatedRoute); + private readonly buildQueueService = inject(BuildQueueService); + private readonly alertService = inject(AlertService); + protected readonly TriggeredByPushTo = TriggeredByPushTo; buildAgent: BuildAgentInformation; agentName: string; @@ -32,14 +44,6 @@ export class BuildAgentDetailsComponent implements OnInit, OnDestroy { readonly faPause = faPause; readonly faPlay = faPlay; - constructor( - private websocketService: JhiWebsocketService, - private buildAgentsService: BuildAgentsService, - private route: ActivatedRoute, - private buildQueueService: BuildQueueService, - private alertService: AlertService, - ) {} - ngOnInit() { this.paramSub = this.route.queryParams.subscribe((params) => { this.agentName = params['agentName']; diff --git a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts index 5e79ceac3c75..b3a9e8fcf5c8 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts +++ b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.ts @@ -1,4 +1,4 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; import { BuildAgentInformation, BuildAgentStatus } from 'app/entities/programming/build-agent-information.model'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { BuildAgentsService } from 'app/localci/build-agents/build-agents.service'; @@ -9,13 +9,25 @@ import { Router } from '@angular/router'; import { BuildAgent } from 'app/entities/programming/build-agent.model'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { AlertService, AlertType } from 'app/core/util/alert.service'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { ArtemisDataTableModule } from 'app/shared/data-table/data-table.module'; +import { NgxDatatableModule } from '@siemens/ngx-datatable'; @Component({ selector: 'jhi-build-agents', + standalone: true, templateUrl: './build-agent-summary.component.html', styleUrl: './build-agent-summary.component.scss', + imports: [ArtemisSharedModule, NgxDatatableModule, ArtemisDataTableModule], }) export class BuildAgentSummaryComponent implements OnInit, OnDestroy { + private readonly websocketService = inject(JhiWebsocketService); + private readonly buildAgentsService = inject(BuildAgentsService); + private readonly buildQueueService = inject(BuildQueueService); + private readonly router = inject(Router); + private readonly modalService = inject(NgbModal); + private readonly alertService = inject(AlertService); + buildAgents: BuildAgentInformation[] = []; buildCapacity = 0; currentBuilds = 0; @@ -29,15 +41,6 @@ export class BuildAgentSummaryComponent implements OnInit, OnDestroy { protected readonly faPause = faPause; protected readonly faPlay = faPlay; - constructor( - private websocketService: JhiWebsocketService, - private buildAgentsService: BuildAgentsService, - private buildQueueService: BuildQueueService, - private router: Router, - private modalService: NgbModal, - private alertService: AlertService, - ) {} - ngOnInit() { this.routerLink = this.router.url; this.load(); diff --git a/src/main/webapp/app/localci/build-agents/build-agents.service.ts b/src/main/webapp/app/localci/build-agents/build-agents.service.ts index c3b388586bcf..bfe25213e61c 100644 --- a/src/main/webapp/app/localci/build-agents/build-agents.service.ts +++ b/src/main/webapp/app/localci/build-agents/build-agents.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, throwError } from 'rxjs'; import { BuildAgentInformation } from 'app/entities/programming/build-agent-information.model'; @@ -8,7 +8,7 @@ import { catchError } from 'rxjs/operators'; export class BuildAgentsService { public adminResourceUrl = 'api/admin'; - constructor(private http: HttpClient) {} + private readonly http = inject(HttpClient); /** * Get all build agents diff --git a/src/test/javascript/spec/component/localci/build-agents/build-agent-details.component.spec.ts b/src/test/javascript/spec/component/localci/build-agents/build-agent-details.component.spec.ts index 0b14d136c97c..606bc59a0ed8 100644 --- a/src/test/javascript/spec/component/localci/build-agents/build-agent-details.component.spec.ts +++ b/src/test/javascript/spec/component/localci/build-agents/build-agent-details.component.spec.ts @@ -5,10 +5,8 @@ import { of, throwError } from 'rxjs'; import { BuildJob } from 'app/entities/programming/build-job.model'; import dayjs from 'dayjs/esm'; import { ArtemisTestModule } from '../../../test.module'; -import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { DataTableComponent } from 'app/shared/data-table/data-table.component'; -import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; -import { NgxDatatableModule } from '@siemens/ngx-datatable'; +import { MockProvider } from 'ng-mocks'; import { BuildAgentInformation, BuildAgentStatus } from '../../../../../../main/webapp/app/entities/programming/build-agent-information.model'; import { RepositoryInfo, TriggeredByPushTo } from 'app/entities/programming/repository-info.model'; import { JobTimingInfo } from 'app/entities/job-timing-info.model'; @@ -125,8 +123,8 @@ describe('BuildAgentDetailsComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, NgxDatatableModule], - declarations: [BuildAgentDetailsComponent, MockPipe(ArtemisTranslatePipe), MockComponent(DataTableComponent)], + imports: [ArtemisTestModule], + declarations: [], providers: [ { provide: JhiWebsocketService, useValue: mockWebsocketService }, { provide: ActivatedRoute, useValue: new MockActivatedRoute({ key: 'ABC123' }) }, diff --git a/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts b/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts index f71ad2c8d920..0d798185a915 100644 --- a/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts +++ b/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts @@ -6,10 +6,8 @@ import { of, throwError } from 'rxjs'; import { BuildJob } from 'app/entities/programming/build-job.model'; import dayjs from 'dayjs/esm'; import { ArtemisTestModule } from '../../../test.module'; -import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { DataTableComponent } from 'app/shared/data-table/data-table.component'; -import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; -import { NgxDatatableModule } from '@siemens/ngx-datatable'; +import { MockProvider } from 'ng-mocks'; import { BuildAgentInformation, BuildAgentStatus } from '../../../../../../main/webapp/app/entities/programming/build-agent-information.model'; import { RepositoryInfo, TriggeredByPushTo } from 'app/entities/programming/repository-info.model'; import { JobTimingInfo } from 'app/entities/job-timing-info.model'; @@ -143,8 +141,8 @@ describe('BuildAgentSummaryComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, NgxDatatableModule], - declarations: [BuildAgentSummaryComponent, MockPipe(ArtemisTranslatePipe), MockComponent(DataTableComponent)], + imports: [ArtemisTestModule], + declarations: [], providers: [ { provide: JhiWebsocketService, useValue: mockWebsocketService }, { provide: BuildAgentsService, useValue: mockBuildAgentsService }, From 82f04e6c4aff3853c6e57dfefa72817e46c86935 Mon Sep 17 00:00:00 2001 From: Aniruddh Zaveri <92953467+az108@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:56:36 +0100 Subject: [PATCH 08/53] Tutorial groups: Migrate tutorial groups service folder to new angular guidelines (#9941) --- .../services/tutorial-group-free-period.service.ts | 6 +++--- .../services/tutorial-group-session.service.ts | 10 ++++------ .../tutorial-groups-configuration.service.ts | 6 +++--- .../services/tutorial-groups.service.ts | 12 +++++------- 4 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/main/webapp/app/course/tutorial-groups/services/tutorial-group-free-period.service.ts b/src/main/webapp/app/course/tutorial-groups/services/tutorial-group-free-period.service.ts index fb56af637fad..ab3043e2a722 100644 --- a/src/main/webapp/app/course/tutorial-groups/services/tutorial-group-free-period.service.ts +++ b/src/main/webapp/app/course/tutorial-groups/services/tutorial-group-free-period.service.ts @@ -1,5 +1,5 @@ import { HttpClient, HttpResponse } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { convertDateFromServer, toISO8601DateTimeString } from 'app/utils/date.utils'; import { map } from 'rxjs/operators'; @@ -15,9 +15,9 @@ export class TutorialGroupFreePeriodDTO { @Injectable({ providedIn: 'root' }) export class TutorialGroupFreePeriodService { - private resourceURL = 'api'; + private httpClient = inject(HttpClient); - constructor(private httpClient: HttpClient) {} + private resourceURL = 'api'; getOneOfConfiguration(courseId: number, tutorialGroupsConfigurationId: number, tutorialGroupFreePeriodId: number): Observable { return this.httpClient diff --git a/src/main/webapp/app/course/tutorial-groups/services/tutorial-group-session.service.ts b/src/main/webapp/app/course/tutorial-groups/services/tutorial-group-session.service.ts index 122a98d6ac52..2f37f5e24ff1 100644 --- a/src/main/webapp/app/course/tutorial-groups/services/tutorial-group-session.service.ts +++ b/src/main/webapp/app/course/tutorial-groups/services/tutorial-group-session.service.ts @@ -1,5 +1,5 @@ import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { convertDateFromServer, toISO8601DateString } from 'app/utils/date.utils'; import { map } from 'rxjs/operators'; @@ -17,12 +17,10 @@ export class TutorialGroupSessionDTO { @Injectable({ providedIn: 'root' }) export class TutorialGroupSessionService { - private resourceURL = 'api'; + private httpClient = inject(HttpClient); + private tutorialGroupFreePeriodService = inject(TutorialGroupFreePeriodService); - constructor( - private httpClient: HttpClient, - private tutorialGroupFreePeriodService: TutorialGroupFreePeriodService, - ) {} + private resourceURL = 'api'; getOneOfTutorialGroup(courseId: number, tutorialGroupId: number, sessionId: number) { return this.httpClient diff --git a/src/main/webapp/app/course/tutorial-groups/services/tutorial-groups-configuration.service.ts b/src/main/webapp/app/course/tutorial-groups/services/tutorial-groups-configuration.service.ts index 96e1d60af650..086122602ae1 100644 --- a/src/main/webapp/app/course/tutorial-groups/services/tutorial-groups-configuration.service.ts +++ b/src/main/webapp/app/course/tutorial-groups/services/tutorial-groups-configuration.service.ts @@ -1,5 +1,5 @@ import { HttpClient, HttpResponse } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { convertDateFromServer, toISO8601DateString } from 'app/utils/date.utils'; import { map } from 'rxjs/operators'; @@ -9,9 +9,9 @@ type EntityResponseType = HttpResponse; @Injectable({ providedIn: 'root' }) export class TutorialGroupsConfigurationService { - private resourceURL = 'api'; + private httpClient = inject(HttpClient); - constructor(private httpClient: HttpClient) {} + private resourceURL = 'api'; getOneOfCourse(courseId: number) { return this.httpClient diff --git a/src/main/webapp/app/course/tutorial-groups/services/tutorial-groups.service.ts b/src/main/webapp/app/course/tutorial-groups/services/tutorial-groups.service.ts index e011c4915199..e51341988010 100644 --- a/src/main/webapp/app/course/tutorial-groups/services/tutorial-groups.service.ts +++ b/src/main/webapp/app/course/tutorial-groups/services/tutorial-groups.service.ts @@ -1,5 +1,5 @@ import { HttpClient, HttpResponse } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { TutorialGroup } from 'app/entities/tutorial-group/tutorial-group.model'; import { Observable } from 'rxjs'; import { StudentDTO } from 'app/entities/student-dto.model'; @@ -15,13 +15,11 @@ type EntityArrayResponseType = HttpResponse; @Injectable({ providedIn: 'root' }) export class TutorialGroupsService { - private resourceURL = 'api'; + private httpClient = inject(HttpClient); + private tutorialGroupSessionService = inject(TutorialGroupSessionService); + private tutorialGroupsConfigurationService = inject(TutorialGroupsConfigurationService); - constructor( - private httpClient: HttpClient, - private tutorialGroupSessionService: TutorialGroupSessionService, - private tutorialGroupsConfigurationService: TutorialGroupsConfigurationService, - ) {} + private resourceURL = 'api'; getUniqueCampusValues(courseId: number): Observable> { return this.httpClient.get(`${this.resourceURL}/courses/${courseId}/tutorial-groups/campus-values`, { observe: 'response' }); From d312eae8c7d53055e5f304bc8361d2ad5e7b4bef Mon Sep 17 00:00:00 2001 From: Michal Kawka <73854755+coolchock@users.noreply.github.com> Date: Tue, 10 Dec 2024 17:58:25 +0100 Subject: [PATCH 09/53] Exam mode: Add exam update audit event (#9956) --- .../java/de/tum/cit/aet/artemis/core/config/Constants.java | 2 ++ .../java/de/tum/cit/aet/artemis/exam/web/ExamResource.java | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java index 843a9034d46c..357a0d5a02e1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java @@ -192,6 +192,8 @@ public final class Constants { public static final String DELETE_EXAM = "DELETE_EXAM"; + public static final String UPDATE_EXAM = "UPDATE_EXAM"; + public static final String ADD_USER_TO_EXAM = "ADD_USER_TO_EXAM"; public static final String REMOVE_USER_FROM_EXAM = "REMOVE_USER_FROM_EXAM"; diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java b/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java index ddebe7b74068..b986d0366683 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java @@ -277,6 +277,10 @@ public ResponseEntity updateExam(@PathVariable Long courseId, @RequestBody Exam savedExam = examRepository.save(updatedExam); + User instructor = userRepository.getUser(); + final var auditEvent = new AuditEvent(instructor.getLogin(), Constants.UPDATE_EXAM, "exam=" + savedExam.getId()); + auditEventRepository.add(auditEvent); + // NOTE: We have to get exercises and groups as we need them for re-scheduling Exam examWithExercises = examService.findByIdWithExerciseGroupsAndExercisesElseThrow(savedExam.getId(), false); From f01b5eb975b3690fe2e647ab768dd72908d75461 Mon Sep 17 00:00:00 2001 From: Ramona Beinstingel <75392103+rabeatwork@users.noreply.github.com> Date: Tue, 10 Dec 2024 18:01:08 +0100 Subject: [PATCH 10/53] Programming exercises: Fix table overflow in rendered markdown (#9957) --- ...gramming-exercise-instruction.component.ts | 23 +++++++++++++++++-- .../course-exercises.component.html | 8 +++++-- .../webapp/app/overview/course-overview.scss | 10 ++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/main/webapp/app/exercises/programming/shared/instructions-render/programming-exercise-instruction.component.ts b/src/main/webapp/app/exercises/programming/shared/instructions-render/programming-exercise-instruction.component.ts index d53b738b3ff1..b978289acf7d 100644 --- a/src/main/webapp/app/exercises/programming/shared/instructions-render/programming-exercise-instruction.component.ts +++ b/src/main/webapp/app/exercises/programming/shared/instructions-render/programming-exercise-instruction.component.ts @@ -304,7 +304,8 @@ export class ProgrammingExerciseInstructionComponent implements OnChanges, OnDes const updatedMarkdown = htmlForMarkdown(this.examExerciseUpdateHighlighterComponent.updatedProblemStatement, this.markdownExtensions); const diffedMarkdown = diff(outdatedMarkdown, updatedMarkdown); const markdownWithoutTasks = this.prepareTasks(diffedMarkdown); - this.renderedMarkdown = this.sanitizer.bypassSecurityTrustHtml(markdownWithoutTasks); + const markdownWithTableStyles = this.addStylesForTables(markdownWithoutTasks); + this.renderedMarkdown = this.sanitizer.bypassSecurityTrustHtml(markdownWithTableStyles ?? markdownWithoutTasks); // Differences between UMLs are ignored, and we only inject the current one setTimeout(() => { const injectUML = this.injectableContentForMarkdownCallbacks[this.injectableContentForMarkdownCallbacks.length - 1]; @@ -317,7 +318,8 @@ export class ProgrammingExerciseInstructionComponent implements OnChanges, OnDes this.injectableContentForMarkdownCallbacks = []; const renderedProblemStatement = htmlForMarkdown(this.exercise.problemStatement, this.markdownExtensions); const markdownWithoutTasks = this.prepareTasks(renderedProblemStatement); - this.renderedMarkdown = this.sanitizer.bypassSecurityTrustHtml(markdownWithoutTasks); + const markdownWithTableStyles = this.addStylesForTables(markdownWithoutTasks); + this.renderedMarkdown = this.sanitizer.bypassSecurityTrustHtml(markdownWithTableStyles ?? markdownWithoutTasks); setTimeout(() => { this.injectableContentForMarkdownCallbacks.forEach((callback) => { callback(); @@ -327,6 +329,23 @@ export class ProgrammingExerciseInstructionComponent implements OnChanges, OnDes } } + addStylesForTables(markdownWithoutTasks: string): string | undefined { + if (!markdownWithoutTasks?.includes(' { + table.style.maxWidth = '100%'; + table.style.overflowX = 'scroll'; + table.style.display = 'block'; + }); + return doc.body.innerHTML; + } + } + prepareTasks(problemStatementHtml: string) { const tasks = Array.from(problemStatementHtml.matchAll(taskRegex)); if (!tasks) { diff --git a/src/main/webapp/app/overview/course-exercises/course-exercises.component.html b/src/main/webapp/app/overview/course-exercises/course-exercises.component.html index bd8e5fceab3b..e4ab1bac6939 100644 --- a/src/main/webapp/app/overview/course-exercises/course-exercises.component.html +++ b/src/main/webapp/app/overview/course-exercises/course-exercises.component.html @@ -1,4 +1,4 @@ -
+
@if (course) { @if (!isShownViaLti) {
@@ -14,7 +14,11 @@ } @if (exerciseSelected) { -
+
} @else { diff --git a/src/main/webapp/app/overview/course-overview.scss b/src/main/webapp/app/overview/course-overview.scss index 718a755e7b56..1720561a290f 100644 --- a/src/main/webapp/app/overview/course-overview.scss +++ b/src/main/webapp/app/overview/course-overview.scss @@ -189,3 +189,13 @@ canvas#complete-chart { opacity: 1; } } + +.exercise-content-sidebar-width { + // magic number is 255px sidebar width + 8px margin + width: calc(100% - 263px) !important; + transition: width 0.1s 0s ease-in-out; +} +.exercise-content-width { + width: 100%; + transition: width 0.1s 0s ease-in-out; +} From 037d35dab6f5265436668d2dbc33183f00f403e1 Mon Sep 17 00:00:00 2001 From: Asli Aykan <56061820+asliayk@users.noreply.github.com> Date: Tue, 10 Dec 2024 18:02:33 +0100 Subject: [PATCH 11/53] Communication: Fix visibility of create channel option for students (#9989) --- .../course-conversations.component.ts | 2 ++ .../app/shared/sidebar/sidebar.component.html | 10 ++++--- src/main/webapp/app/types/sidebar.ts | 1 + .../shared/sidebar/sidebar.component.spec.ts | 26 ++++++++++++++++--- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts index f65878c794e1..bdc17d480a70 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts @@ -40,6 +40,7 @@ import { CourseSidebarService } from 'app/overview/course-sidebar.service'; import { LayoutService } from 'app/shared/breakpoints/layout.service'; import { CustomBreakpointNames } from 'app/shared/breakpoints/breakpoints.service'; import { Posting, PostingType, SavedPostStatus, SavedPostStatusMap } from 'app/entities/metis/posting.model'; +import { canCreateChannel } from 'app/shared/metis/conversations/conversation-permissions.utils'; const DEFAULT_CHANNEL_GROUPS: AccordionGroups = { favoriteChannels: { entityData: [] }, @@ -424,6 +425,7 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { ungroupedData: this.sidebarConversations, showAccordionLeadingIcon: true, messagingEnabled: isMessagingEnabled(this.course), + canCreateChannel: canCreateChannel(this.course!), }; } diff --git a/src/main/webapp/app/shared/sidebar/sidebar.component.html b/src/main/webapp/app/shared/sidebar/sidebar.component.html index e0a664240503..5b812f79d179 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar.component.html +++ b/src/main/webapp/app/shared/sidebar/sidebar.component.html @@ -35,10 +35,12 @@ } - + @if (sidebarData?.canCreateChannel) { + + } +
+ + diff --git a/src/main/webapp/app/admin/cleanup-service/cleanup-operation-modal.component.ts b/src/main/webapp/app/admin/cleanup-service/cleanup-operation-modal.component.ts new file mode 100644 index 000000000000..be398b021798 --- /dev/null +++ b/src/main/webapp/app/admin/cleanup-service/cleanup-operation-modal.component.ts @@ -0,0 +1,123 @@ +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { Component, OnInit, inject, input } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { CleanupOperation } from 'app/admin/cleanup-service/cleanup-operation.model'; +import { CleanupCount, DataCleanupService } from 'app/admin/cleanup-service/data-cleanup.service'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { Observable, Subject } from 'rxjs'; +import { faCheckCircle, faTimes } from '@fortawesome/free-solid-svg-icons'; + +@Component({ + selector: 'jhi-cleanup-operation-modal', + templateUrl: './cleanup-operation-modal.component.html', + standalone: true, + imports: [TranslateDirective, ArtemisSharedModule], +}) +export class CleanupOperationModalComponent implements OnInit { + operation = input.required(); + counts: CleanupCount = { totalCount: 0 }; + operationExecuted = false; + + private dialogErrorSource = new Subject(); + dialogError = this.dialogErrorSource.asObservable(); + + public activeModal: NgbActiveModal = inject(NgbActiveModal); + private dataCleanupService: DataCleanupService = inject(DataCleanupService); + + faTimes = faTimes; + faCheckCircle = faCheckCircle; + + /** + * Fetch keys from the CleanupCount object for iteration. + */ + get cleanupKeys(): (keyof CleanupCount)[] { + return Object.keys(this.counts) as (keyof CleanupCount)[]; + } + + /** + * Close the modal. + */ + close(): void { + this.activeModal.close(); + } + + /** + * Initialize component: fetch initial counts for the operation. + */ + ngOnInit(): void { + this.updateCounts(); + } + + /** + * Execute the cleanup operation and update counts afterward. + */ + executeCleanupOperation(): void { + const operationHandler = { + next: () => { + this.operationExecuted = true; + this.updateCounts(); + }, + error: (error: any) => { + this.dialogErrorSource.next(error instanceof HttpErrorResponse ? error.message : 'An unexpected error occurred.'); + }, + }; + + switch (this.operation().name) { + case 'deleteOrphans': + this.dataCleanupService.deleteOrphans().subscribe(operationHandler); + break; + case 'deletePlagiarismComparisons': + this.dataCleanupService.deletePlagiarismComparisons(this.operation().deleteFrom, this.operation().deleteTo).subscribe(operationHandler); + break; + case 'deleteNonRatedResults': + this.dataCleanupService.deleteNonRatedResults(this.operation().deleteFrom, this.operation().deleteTo).subscribe(operationHandler); + break; + case 'deleteOldRatedResults': + this.dataCleanupService.deleteOldRatedResults(this.operation().deleteFrom, this.operation().deleteTo).subscribe(operationHandler); + break; + case 'deleteOldSubmissionVersions': + this.dataCleanupService.deleteOldSubmissionVersions(this.operation().deleteFrom, this.operation().deleteTo).subscribe(operationHandler); + break; + } + } + + /** + * Fetch counts for the operation. + */ + private fetchCounts(): Observable> { + switch (this.operation().name) { + case 'deleteOrphans': + return this.dataCleanupService.countOrphans(); + case 'deletePlagiarismComparisons': + return this.dataCleanupService.countPlagiarismComparisons(this.operation().deleteFrom, this.operation().deleteTo); + case 'deleteNonRatedResults': + return this.dataCleanupService.countNonRatedResults(this.operation().deleteFrom, this.operation().deleteTo); + case 'deleteOldRatedResults': + return this.dataCleanupService.countOldRatedResults(this.operation().deleteFrom, this.operation().deleteTo); + case 'deleteOldSubmissionVersions': + return this.dataCleanupService.countOldSubmissionVersions(this.operation().deleteFrom, this.operation().deleteTo); + default: + throw new Error(`Unsupported operation: ${this.operation().name}`); + } + } + + /** + * Fetch updated counts after operation execution. + */ + private updateCounts(): void { + this.fetchCounts().subscribe({ + next: (response: HttpResponse) => { + this.counts = response.body!; + }, + error: () => this.dialogErrorSource.next('An error occurred while fetching updated counts.'), + }); + } + + /** + * Getter to check if there are any entries to delete. + */ + get hasEntriesToDelete(): boolean { + return Object.values(this.counts).some((count) => count > 0); + } +} diff --git a/src/main/webapp/app/admin/cleanup-service/cleanup-service.component.html b/src/main/webapp/app/admin/cleanup-service/cleanup-service.component.html index aa1e021f066e..f53a572ae74c 100644 --- a/src/main/webapp/app/admin/cleanup-service/cleanup-service.component.html +++ b/src/main/webapp/app/admin/cleanup-service/cleanup-service.component.html @@ -18,6 +18,7 @@

+ @if (operation.name !== 'deleteOrphans') { @@ -60,17 +61,9 @@

} - + } diff --git a/src/main/webapp/app/admin/cleanup-service/cleanup-service.component.ts b/src/main/webapp/app/admin/cleanup-service/cleanup-service.component.ts index fab6e37e6217..00a6965915d5 100644 --- a/src/main/webapp/app/admin/cleanup-service/cleanup-service.component.ts +++ b/src/main/webapp/app/admin/cleanup-service/cleanup-service.component.ts @@ -3,23 +3,26 @@ import dayjs from 'dayjs/esm'; import { CleanupOperation } from 'app/admin/cleanup-service/cleanup-operation.model'; import { convertDateFromServer } from 'app/utils/date.utils'; import { Subject } from 'rxjs'; -import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { HttpResponse } from '@angular/common/http'; import { CleanupServiceExecutionRecordDTO, DataCleanupService } from 'app/admin/cleanup-service/data-cleanup.service'; -import { Observer } from 'rxjs'; import { FormDateTimePickerModule } from 'app/shared/date-time-picker/date-time-picker.module'; import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { CleanupOperationModalComponent } from 'app/admin/cleanup-service/cleanup-operation-modal.component'; @Component({ selector: 'jhi-cleanup-service', templateUrl: './cleanup-service.component.html', standalone: true, - imports: [FormDateTimePickerModule, ArtemisSharedModule], + imports: [FormDateTimePickerModule, ArtemisSharedModule, ArtemisSharedComponentModule], }) export class CleanupServiceComponent implements OnInit { private dialogErrorSource = new Subject(); dialogError = this.dialogErrorSource.asObservable(); private dataCleanupService: DataCleanupService = inject(DataCleanupService); + private modalService: NgbModal = inject(NgbModal); cleanupOperations: CleanupOperation[] = [ { @@ -50,6 +53,13 @@ export class CleanupServiceComponent implements OnInit { lastExecuted: undefined, datesValid: signal(true), }, + { + name: 'deleteOldSubmissionVersions', + deleteFrom: dayjs().subtract(12, 'months'), + deleteTo: dayjs().subtract(6, 'months'), + lastExecuted: undefined, + datesValid: signal(true), + }, ]; ngOnInit(): void { @@ -70,45 +80,16 @@ export class CleanupServiceComponent implements OnInit { }); } - executeCleanupOperation(operation: CleanupOperation): void { - const subscriptionHandler = this.handleResponse(operation); - - switch (operation.name) { - case 'deleteOrphans': - this.dataCleanupService.deleteOrphans().subscribe(subscriptionHandler); - break; - case 'deletePlagiarismComparisons': - this.dataCleanupService.deletePlagiarismComparisons(operation.deleteFrom, operation.deleteTo).subscribe(subscriptionHandler); - break; - case 'deleteNonRatedResults': - this.dataCleanupService.deleteNonRatedResults(operation.deleteFrom, operation.deleteTo).subscribe(subscriptionHandler); - break; - case 'deleteOldRatedResults': - this.dataCleanupService.deleteOldRatedResults(operation.deleteFrom, operation.deleteTo).subscribe(subscriptionHandler); - break; - } - } - - private handleResponse(operation: CleanupOperation): Observer> { - return { - next: (response: HttpResponse) => { - this.dialogErrorSource.next(''); - const executionDateFromServer = convertDateFromServer(response.body!.executionDate); - operation.lastExecuted = executionDateFromServer; - }, - error: (error: any) => { - if (error instanceof HttpErrorResponse) { - this.dialogErrorSource.next(error.message); - } else { - this.dialogErrorSource.next('An unexpected error occurred.'); - } - }, - complete: () => {}, - }; - } - validateDates(operation: CleanupOperation): void { const datesValid = operation.deleteFrom && operation.deleteTo && dayjs(operation.deleteTo).isAfter(dayjs(operation.deleteFrom)); operation.datesValid.set(datesValid); } + + /** + * Handles displaying the modal with operation details and counts. + */ + openCleanupOperationModal(operation: CleanupOperation): void { + const modalRef = this.modalService.open(CleanupOperationModalComponent, { size: 'lg', backdrop: 'static' }); + modalRef.componentInstance.operation = signal(operation); + } } diff --git a/src/main/webapp/app/admin/cleanup-service/data-cleanup.service.ts b/src/main/webapp/app/admin/cleanup-service/data-cleanup.service.ts index 4782b5055674..4988c1c903ab 100644 --- a/src/main/webapp/app/admin/cleanup-service/data-cleanup.service.ts +++ b/src/main/webapp/app/admin/cleanup-service/data-cleanup.service.ts @@ -9,6 +9,46 @@ export interface CleanupServiceExecutionRecordDTO { jobType: string; } +export interface CleanupCount { + totalCount: number; +} + +export interface OrphanCleanupCountDTO extends CleanupCount { + orphanFeedback: number; + orphanLongFeedbackText: number; + orphanTextBlock: number; + orphanStudentScore: number; + orphanTeamScore: number; + orphanFeedbackForOrphanResults: number; + orphanLongFeedbackTextForOrphanResults: number; + orphanTextBlockForOrphanResults: number; + orphanRating: number; + orphanResultsWithoutParticipation: number; +} + +export interface PlagiarismComparisonCleanupCountDTO extends CleanupCount { + plagiarismComparison: number; + plagiarismElements: number; + plagiarismSubmissions: number; + plagiarismMatches: number; +} + +export interface NonLatestNonRatedResultsCleanupCountDTO extends CleanupCount { + longFeedbackText: number; + textBlock: number; + feedback: number; +} + +export interface NonLatestRatedResultsCleanupCountDTO extends CleanupCount { + longFeedbackText: number; + textBlock: number; + feedback: number; +} + +export interface SubmissionVersionsCleanupCountDTO extends CleanupCount { + submissionVersions: number; +} + @Injectable({ providedIn: 'root' }) export class DataCleanupService { private readonly adminResourceUrl = 'api/admin/cleanup'; @@ -66,6 +106,20 @@ export class DataCleanupService { }); } + /** + * Send DELETE request to delete old submission versions within a specific date range. + * @param deleteFrom the start date from which old rated results should be deleted + * @param deleteTo the end date until which old rated results should be deleted + */ + deleteOldSubmissionVersions(deleteFrom: dayjs.Dayjs, deleteTo: dayjs.Dayjs): Observable> { + const deleteFromString = convertDateFromClient(deleteFrom)!; + const deleteToString = convertDateFromClient(deleteTo)!; + return this.http.delete(`${this.adminResourceUrl}/old-submission-versions`, { + params: { deleteFrom: deleteFromString, deleteTo: deleteToString }, + observe: 'response', + }); + } + /** * Send GET request to get the last executions. * @returns An observable of type HttpResponse. @@ -75,4 +129,74 @@ export class DataCleanupService { observe: 'response', }); } + + /** + * Send GET request to count orphaned data. + * @returns An observable of type HttpResponse. + */ + countOrphans(): Observable> { + return this.http.get(`${this.adminResourceUrl}/orphans/count`, { + observe: 'response', + }); + } + + /** + * Send GET request to count plagiarism comparisons within a specific date range. + * @param deleteFrom the start date for counting + * @param deleteTo the end date for counting + * @returns An observable of type HttpResponse. + */ + countPlagiarismComparisons(deleteFrom: dayjs.Dayjs, deleteTo: dayjs.Dayjs): Observable> { + const deleteFromString = convertDateFromClient(deleteFrom)!; + const deleteToString = convertDateFromClient(deleteTo)!; + return this.http.get(`${this.adminResourceUrl}/plagiarism-comparisons/count`, { + params: { deleteFrom: deleteFromString, deleteTo: deleteToString }, + observe: 'response', + }); + } + + /** + * Send GET request to count non-rated results within a specific date range. + * @param deleteFrom the start date for counting + * @param deleteTo the end date for counting + * @returns An observable of type HttpResponse. + */ + countNonRatedResults(deleteFrom: dayjs.Dayjs, deleteTo: dayjs.Dayjs): Observable> { + const deleteFromString = convertDateFromClient(deleteFrom)!; + const deleteToString = convertDateFromClient(deleteTo)!; + return this.http.get(`${this.adminResourceUrl}/non-rated-results/count`, { + params: { deleteFrom: deleteFromString, deleteTo: deleteToString }, + observe: 'response', + }); + } + + /** + * Send GET request to count old rated results within a specific date range. + * @param deleteFrom the start date for counting + * @param deleteTo the end date for counting + * @returns An observable of type HttpResponse. + */ + countOldRatedResults(deleteFrom: dayjs.Dayjs, deleteTo: dayjs.Dayjs): Observable> { + const deleteFromString = convertDateFromClient(deleteFrom)!; + const deleteToString = convertDateFromClient(deleteTo)!; + return this.http.get(`${this.adminResourceUrl}/old-rated-results/count`, { + params: { deleteFrom: deleteFromString, deleteTo: deleteToString }, + observe: 'response', + }); + } + + /** + * Send GET request to count old submission versions within a specific date range. + * @param deleteFrom the start date for counting + * @param deleteTo the end date for counting + * @returns An observable of type HttpResponse. + */ + countOldSubmissionVersions(deleteFrom: dayjs.Dayjs, deleteTo: dayjs.Dayjs): Observable> { + const deleteFromString = convertDateFromClient(deleteFrom)!; + const deleteToString = convertDateFromClient(deleteTo)!; + return this.http.get(`${this.adminResourceUrl}/old-submission-versions/count`, { + params: { deleteFrom: deleteFromString, deleteTo: deleteToString }, + observe: 'response', + }); + } } diff --git a/src/main/webapp/i18n/de/cleanupService.json b/src/main/webapp/i18n/de/cleanupService.json index aa509515921c..6eb73b1a8bfc 100644 --- a/src/main/webapp/i18n/de/cleanupService.json +++ b/src/main/webapp/i18n/de/cleanupService.json @@ -15,13 +15,50 @@ "deleteOldSubmissionVersions": "Alte Teilnahmeversionen löschen", "deleteOldFeedback": "Altes Feedback löschen" }, + "tooltip": { + "deleteOrphans": "Löscht verwaiste Entitäten, die nicht mehr mit gültigen Ergebnissen oder Teilnahmen verbunden sind. Dazu gehören Feedback, Textblöcke und Bewertungen, die sich auf ungültige Ergebnisse, Teilnahmen oder Einreichungen beziehen.", + "deletePlagiarismComparisons": "Löscht Plagiatsvergleiche mit dem Status \"None\", die zu Kursen innerhalb des angegebenen Datumsbereichs gehören. Ein Status von \"None\" bedeutet, dass der Plagiatsfall noch nicht überprüft wurde.", + "deleteNonRatedResults": "Löscht nicht bewertete Ergebnisse, mit Ausnahme des neuesten nicht bewerteten Ergebnisses für jede Teilnahme (um Kompetenzergebnisse berechnen zu können), innerhalb des angegebenen Datumsbereichs, zusammen mit zugehörigen langen Feedback-Texten, Textblöcken, Feedback-Elementen und Teilnehmerbewertungen.", + "deleteOldRatedResults": "Löscht bewertete Ergebnisse, mit Ausnahme des neuesten bewerteten Ergebnisses für jede Teilnahme, für Kurse, die innerhalb des angegebenen Datumsbereichs durchgeführt wurden. Löscht auch zugehörige lange Feedback-Texte, Textblöcke, Feedback-Elemente und Teilnehmerbewertungen.", + "deleteOldSubmissionVersions": "Löscht alte Teilnahmeversionen. Die letzte Teilnahmeversion wird nicht gelöscht, da sie immer in der entsprechenden Unterklasse von Submission gespeichert wird", + "deleteOldFeedback": "Löscht alte Feedback-Versionen." + }, "execute": { - "question": "Bist du sicher, dass du die Operation {{ title }} ausführen möchtest? Diese Aktion kann NICHT rückgängig gemacht werden!", + "question": "Bist du sicher, dass du die Operation {{ operationName }} für den Zeitraum von {{deleteFrom}} bis {{deleteTo}} ausführen möchtest? Diese Aktion kann NICHT rückgängig gemacht werden!", + "questionWithoutDates": "Bist du sicher, dass du die Operation {{ operationName }} ausführen möchtest? Diese Aktion kann NICHT rückgängig gemacht werden!", "typeOperationNameToConfirm": "Bitte gib den Namen der Operation ein, um zu bestätigen." }, + "modal": { + "affectedEntities": "Diese Operation wird die folgenden Entitäten löschen:", + "noEntriesToDelete": "Keine Einträge zum Löschen vorhanden" + }, "notRunYet": "Diese Operation wurde noch nicht ausgeführt", "error": { "datesInvalid": "'Löschen bis' muss nach 'Löschen von' liegen" + }, + "entities": { + "orphanFeedback": "Verwaistes Feedback", + "orphanLongFeedbackText": "Verwaister langer Feedback-Text", + "orphanTextBlock": "Verwaister Textblock", + "orphanStudentScore": "Verwaiste Studentenbewertung", + "orphanTeamScore": "Verwaiste Teambewertung", + "orphanFeedbackForOrphanResults": "Verwaistes Feedback für verwaiste Ergebnisse", + "orphanLongFeedbackTextForOrphanResults": "Verwaister langer Feedback-Text für verwaiste Ergebnisse", + "orphanTextBlockForOrphanResults": "Verwaister Textblock für verwaiste Ergebnisse", + "orphanRating": "Verwaiste Bewertung", + "orphanResultsWithoutParticipation": "Verwaiste Ergebnisse ohne Teilnahme", + "plagiarismComparison": "Plagiatsvergleiche", + "plagiarismElements": "Plagiatselemente", + "plagiarismSubmissions": "Plagiatseingaben", + "plagiarismMatches": "Plagiatsübereinstimmungen", + "longFeedbackText": "Langer Feedback-Text", + "textBlock": "Textblock", + "feedback": "Feedback", + "submissionVersions": "Teilnahmeversionen" + }, + "button": { + "execute": "Ausführen", + "close": "Schließen" } } } diff --git a/src/main/webapp/i18n/en/cleanupService.json b/src/main/webapp/i18n/en/cleanupService.json index 569ca3932aa1..1f8452d4874a 100644 --- a/src/main/webapp/i18n/en/cleanupService.json +++ b/src/main/webapp/i18n/en/cleanupService.json @@ -10,18 +10,55 @@ "operation": { "deleteOrphans": "Delete orphans", "deletePlagiarismComparisons": "Delete plagiarism comparisons", - "deleteNonRatedResults": "Delete non-rated results", - "deleteOldRatedResults": "Delete old rated results", + "deleteNonRatedResults": "Delete feedback of old non-rated results", + "deleteOldRatedResults": "Delete feedback of old rated results", "deleteOldSubmissionVersions": "Delete old submission versions", "deleteOldFeedback": "Delete old feedback versions" }, + "tooltip": { + "deleteOrphans": "Deletes orphaned entities that are no longer associated with valid results or participations. This includes feedback, text blocks, and scores that reference null results, participations, or submissions.", + "deletePlagiarismComparisons": "Deletes plagiarism comparisons with a status of \"None\" that belong to courses within the specified date range. A status of \"None\" indicates that the plagiarism incident has not yet been reviewed.", + "deleteNonRatedResults": "Deletes non-rated results, excluding the latest non-rated result for each participation (to be able to compute Competencies Scores), within the specified date range, along with associated long feedback texts, text blocks, feedback items, and participant scores.", + "deleteOldRatedResults": "Deletes rated results, excluding the latest rated result for each participation, for courses conducted within the specified date range. Also deletes associated long feedback texts, text blocks, feedback items, and participant scores.", + "deleteOldSubmissionVersions": "Deletes old submission versions. The last submission will not be deleted, as it is always stored in the corresponding Submission subclass.", + "deleteOldFeedback": "Delete old feedback versions" + }, "execute": { - "question": "Are you sure you want to execute the operation {{ title }}? This action can NOT be undone!", + "question": "Are you sure you want to execute the operation {{ operationName }} for the period from {{deleteFrom}} to {{deleteTo}}? This action cannot be undone!", + "questionWithoutDates": "Are you sure you want to execute the operation {{ operationName }}? This action cannot be undone!", "typeOperationNameToConfirm": "Please type in the name of the operation to confirm." }, + "modal": { + "affectedEntities": "This operation will delete the following entities:", + "noEntriesToDelete": "No entries to delete" + }, "notRunYet": "This operation has not been run yet", "error": { "datesInvalid": "'Delete to' must be after 'Delete from'" + }, + "entities": { + "orphanFeedback": "Orphan feedback", + "orphanLongFeedbackText": "Orphan long feedback text", + "orphanTextBlock": "Orphan text block", + "orphanStudentScore": "Orphan student score", + "orphanTeamScore": "Orphan team score", + "orphanFeedbackForOrphanResults": "Orphan feedback for orphan results", + "orphanLongFeedbackTextForOrphanResults": "Orphan long feedback text for orphan results", + "orphanTextBlockForOrphanResults": "Orphan text block for orphan results", + "orphanRating": "Orphan rating", + "orphanResultsWithoutParticipation": "Orphan results without participation", + "plagiarismComparison": "Plagiarism comparisons", + "plagiarismElements": "Plagiarism elements", + "plagiarismSubmissions": "Plagiarism submissions", + "plagiarismMatches": "Plagiarism matches", + "longFeedbackText": "Long feedback text", + "textBlock": "Text block", + "feedback": "Feedback", + "submissionVersions": "Submission versions" + }, + "button": { + "execute": "Execute", + "close": "Close" } } } diff --git a/src/test/java/de/tum/cit/aet/artemis/core/CleanupIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/core/CleanupIntegrationTest.java index 9b0fc4767270..af69bee0e039 100644 --- a/src/test/java/de/tum/cit/aet/artemis/core/CleanupIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/core/CleanupIntegrationTest.java @@ -38,14 +38,22 @@ import de.tum.cit.aet.artemis.core.domain.Language; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.dto.CleanupServiceExecutionRecordDTO; +import de.tum.cit.aet.artemis.core.dto.NonLatestNonRatedResultsCleanupCountDTO; +import de.tum.cit.aet.artemis.core.dto.NonLatestRatedResultsCleanupCountDTO; +import de.tum.cit.aet.artemis.core.dto.OrphanCleanupCountDTO; +import de.tum.cit.aet.artemis.core.dto.PlagiarismComparisonCleanupCountDTO; +import de.tum.cit.aet.artemis.core.dto.SubmissionVersionsCleanupCountDTO; import de.tum.cit.aet.artemis.core.repository.cleanup.CleanupJobExecutionRepository; import de.tum.cit.aet.artemis.core.test_repository.CourseTestRepository; import de.tum.cit.aet.artemis.exercise.domain.Submission; +import de.tum.cit.aet.artemis.exercise.domain.SubmissionVersion; import de.tum.cit.aet.artemis.exercise.domain.Team; import de.tum.cit.aet.artemis.exercise.participation.util.ParticipationFactory; import de.tum.cit.aet.artemis.exercise.participation.util.ParticipationUtilService; import de.tum.cit.aet.artemis.exercise.repository.ExerciseTestRepository; +import de.tum.cit.aet.artemis.exercise.repository.SubmissionVersionRepository; import de.tum.cit.aet.artemis.exercise.repository.TeamRepository; +import de.tum.cit.aet.artemis.exercise.test_repository.SubmissionTestRepository; import de.tum.cit.aet.artemis.plagiarism.domain.PlagiarismComparison; import de.tum.cit.aet.artemis.plagiarism.domain.PlagiarismMatch; import de.tum.cit.aet.artemis.plagiarism.domain.PlagiarismSubmission; @@ -57,6 +65,7 @@ import de.tum.cit.aet.artemis.shared.base.AbstractSpringIntegrationJenkinsGitlabTest; import de.tum.cit.aet.artemis.text.domain.TextBlock; import de.tum.cit.aet.artemis.text.domain.TextExercise; +import de.tum.cit.aet.artemis.text.domain.TextSubmission; import de.tum.cit.aet.artemis.text.repository.TextExerciseRepository; import de.tum.cit.aet.artemis.text.util.TextExerciseFactory; import de.tum.cit.aet.artemis.text.util.TextExerciseUtilService; @@ -120,6 +129,12 @@ class CleanupIntegrationTest extends AbstractSpringIntegrationJenkinsGitlabTest @Autowired private TeamRepository teamRepository; + @Autowired + private SubmissionTestRepository submissionRepository; + + @Autowired + private SubmissionVersionRepository submissionVersionRepository; + private Course oldCourse; private Course newCourse; @@ -214,6 +229,20 @@ void testDeleteOrphans() throws Exception { orphanRating.setResult(orphanResult); orphanRating = ratingRepository.save(orphanRating); + var counts = request.get("/api/admin/cleanup/orphans/count", HttpStatus.OK, OrphanCleanupCountDTO.class); + + assertThat(counts).isNotNull(); + assertThat(counts.orphanFeedback()).isEqualTo(0); + assertThat(counts.orphanLongFeedbackText()).isEqualTo(0); + assertThat(counts.orphanTextBlock()).isEqualTo(0); + assertThat(counts.orphanStudentScore()).isEqualTo(1); + assertThat(counts.orphanTeamScore()).isEqualTo(1); + assertThat(counts.orphanFeedbackForOrphanResults()).isEqualTo(1); + assertThat(counts.orphanLongFeedbackTextForOrphanResults()).isEqualTo(1); + assertThat(counts.orphanTextBlockForOrphanResults()).isEqualTo(1); + assertThat(counts.orphanRating()).isEqualTo(1); + assertThat(counts.orphanResultsWithoutParticipation()).isEqualTo(1); + var responseBody = request.delete("/api/admin/cleanup/orphans", new LinkedMultiValueMap<>(), null, CleanupServiceExecutionRecordDTO.class, HttpStatus.OK); assertThat(responseBody.jobType()).isEqualTo("deleteOrphans"); @@ -268,6 +297,15 @@ void testDeletePlagiarismComparisons() throws Exception { LinkedMultiValueMap params = new LinkedMultiValueMap<>(); params.add("deleteFrom", DELETE_FROM.toString()); params.add("deleteTo", DELETE_TO.toString()); + + var counts = request.get("/api/admin/cleanup/plagiarism-comparisons/count", HttpStatus.OK, PlagiarismComparisonCleanupCountDTO.class, params); + + assertThat(counts).isNotNull(); + assertThat(counts.plagiarismComparison()).isEqualTo(1); + assertThat(counts.plagiarismElements()).isEqualTo(0); + assertThat(counts.plagiarismMatches()).isEqualTo(1); + assertThat(counts.plagiarismSubmissions()).isEqualTo(2); + var responseBody = request.delete("/api/admin/cleanup/plagiarism-comparisons", params, null, CleanupServiceExecutionRecordDTO.class, HttpStatus.OK); assertThat(responseBody.jobType()).isEqualTo("deletePlagiarismComparisons"); @@ -385,6 +423,14 @@ void testDeleteNonRatedResults() throws Exception { LinkedMultiValueMap params = new LinkedMultiValueMap<>(); params.add("deleteFrom", DELETE_FROM.toString()); params.add("deleteTo", DELETE_TO.toString()); + + var counts = request.get("/api/admin/cleanup/non-rated-results/count", HttpStatus.OK, NonLatestNonRatedResultsCleanupCountDTO.class, params); + + assertThat(counts).isNotNull(); + assertThat(counts.longFeedbackText()).isEqualTo(1); + assertThat(counts.textBlock()).isEqualTo(1); + assertThat(counts.feedback()).isEqualTo(1); + var responseBody = request.delete("/api/admin/cleanup/non-rated-results", params, null, CleanupServiceExecutionRecordDTO.class, HttpStatus.OK); assertThat(responseBody.jobType()).isEqualTo("deleteNonRatedResults"); @@ -475,6 +521,14 @@ void testDeleteOldRatedResults() throws Exception { LinkedMultiValueMap params = new LinkedMultiValueMap<>(); params.add("deleteFrom", DELETE_FROM.toString()); params.add("deleteTo", DELETE_TO.toString()); + + var counts = request.get("/api/admin/cleanup/old-rated-results/count", HttpStatus.OK, NonLatestRatedResultsCleanupCountDTO.class, params); + + assertThat(counts).isNotNull(); + assertThat(counts.longFeedbackText()).isEqualTo(1); + assertThat(counts.textBlock()).isEqualTo(1); + assertThat(counts.feedback()).isEqualTo(1); + var responseBody = request.delete("/api/admin/cleanup/old-rated-results", params, null, CleanupServiceExecutionRecordDTO.class, HttpStatus.OK); assertThat(responseBody.jobType()).isEqualTo("deleteRatedResults"); @@ -501,6 +555,38 @@ void testDeleteOldRatedResults() throws Exception { assertThat(textBlockRepository.findById(newTextBlock2.getId())).isNotEmpty(); } + @Test + @WithMockUser(roles = "ADMIN") + void testDeleteOldSubmissionVersions() throws Exception { + + TextSubmission submission = ParticipationFactory.generateTextSubmission("submissionText", Language.ENGLISH, true); + submission = submissionRepository.save(submission); + SubmissionVersion submissionVersion1 = ParticipationFactory.generateSubmissionVersion("test1", submission, student); + submissionVersion1 = submissionVersionRepository.save(submissionVersion1); + SubmissionVersion submissionVersion2 = ParticipationFactory.generateSubmissionVersion("test2", submission, student); + submissionVersion2 = submissionVersionRepository.save(submissionVersion2); + SubmissionVersion submissionVersion3 = ParticipationFactory.generateSubmissionVersion("test2", submission, student); + submissionVersion3 = submissionVersionRepository.save(submissionVersion3); + + LinkedMultiValueMap params = new LinkedMultiValueMap<>(); + params.add("deleteFrom", ZonedDateTime.now().minusMonths(1).toString()); + params.add("deleteTo", ZonedDateTime.now().plusMonths(1).toString()); + + var counts = request.get("/api/admin/cleanup/old-submission-versions/count", HttpStatus.OK, SubmissionVersionsCleanupCountDTO.class, params); + + assertThat(counts).isNotNull(); + assertThat(counts.submissionVersions()).isEqualTo(3); + + var responseBody = request.delete("/api/admin/cleanup/old-submission-versions", params, null, CleanupServiceExecutionRecordDTO.class, HttpStatus.OK); + + assertThat(responseBody.jobType()).isEqualTo("deleteSubmissionVersions"); + assertThat(responseBody.executionDate()).isNotNull(); + + assertThat(submissionVersionRepository.findById(submissionVersion1.getId())).isEmpty(); + assertThat(submissionVersionRepository.findById(submissionVersion2.getId())).isEmpty(); + assertThat(submissionVersionRepository.findById(submissionVersion3.getId())).isEmpty(); + } + @Test @WithMockUser(roles = "ADMIN") void testGetLastExecutions() throws Exception { @@ -528,10 +614,16 @@ void testGetLastExecutions() throws Exception { @Test @WithMockUser(roles = "USER") void testUnauthorizedAccess() throws Exception { - request.postWithoutResponseBody("/api/admin/cleanup/orphans", HttpStatus.FORBIDDEN, new LinkedMultiValueMap<>()); - request.postWithoutResponseBody("/api/admin/cleanup/plagiarism-comparisons", HttpStatus.FORBIDDEN, new LinkedMultiValueMap<>()); - request.postWithoutResponseBody("/api/admin/cleanup/non-rated-results", HttpStatus.FORBIDDEN, new LinkedMultiValueMap<>()); - request.postWithoutResponseBody("/api/admin/cleanup/old-rated-results", HttpStatus.FORBIDDEN, new LinkedMultiValueMap<>()); + request.delete("/api/admin/cleanup/orphans", HttpStatus.FORBIDDEN, CleanupServiceExecutionRecordDTO.class); + request.get("/api/admin/cleanup/orphans/count", HttpStatus.FORBIDDEN, OrphanCleanupCountDTO.class); + request.delete("/api/admin/cleanup/plagiarism-comparisons", HttpStatus.FORBIDDEN, CleanupServiceExecutionRecordDTO.class); + request.get("/api/admin/cleanup/plagiarism-comparisons/count", HttpStatus.FORBIDDEN, PlagiarismComparisonCleanupCountDTO.class); + request.delete("/api/admin/cleanup/non-rated-results", HttpStatus.FORBIDDEN, CleanupServiceExecutionRecordDTO.class); + request.get("/api/admin/cleanup/non-rated-results/count", HttpStatus.FORBIDDEN, NonLatestRatedResultsCleanupCountDTO.class); + request.delete("/api/admin/cleanup/old-rated-results", HttpStatus.FORBIDDEN, CleanupServiceExecutionRecordDTO.class); + request.get("/api/admin/cleanup/old-rated-results/count", HttpStatus.FORBIDDEN, NonLatestRatedResultsCleanupCountDTO.class); + request.delete("/api/admin/cleanup/old-submission-versions", HttpStatus.FORBIDDEN, CleanupServiceExecutionRecordDTO.class); + request.get("/api/admin/cleanup/old-submission-versions/count", HttpStatus.FORBIDDEN, SubmissionVersionsCleanupCountDTO.class); request.get("/api/admin/cleanup/last-executions", HttpStatus.FORBIDDEN, List.class); } diff --git a/src/test/javascript/spec/component/admin/cleanup-service/cleanup-oparation-modal.component.spec.ts b/src/test/javascript/spec/component/admin/cleanup-service/cleanup-oparation-modal.component.spec.ts new file mode 100644 index 000000000000..492764985607 --- /dev/null +++ b/src/test/javascript/spec/component/admin/cleanup-service/cleanup-oparation-modal.component.spec.ts @@ -0,0 +1,156 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { of, throwError } from 'rxjs'; +import dayjs from 'dayjs/esm'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { CleanupOperationModalComponent } from 'app/admin/cleanup-service/cleanup-operation-modal.component'; +import { DataCleanupService, PlagiarismComparisonCleanupCountDTO } from 'app/admin/cleanup-service/data-cleanup.service'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { ArtemisSharedModule } from 'app/shared/shared.module'; +import { signal } from '@angular/core'; +import { MockDirective } from 'ng-mocks'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { TranslateService } from '@ngx-translate/core'; + +describe('CleanupOperationModalComponent', () => { + let comp: CleanupOperationModalComponent; + let fixture: ComponentFixture; + let cleanupService: DataCleanupService; + let activeModal: NgbActiveModal; + + const mockCleanupService = { + deletePlagiarismComparisons: jest.fn(), + countPlagiarismComparisons: jest.fn(), + }; + + const mockActiveModal = { + close: jest.fn(), + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CleanupOperationModalComponent, ArtemisSharedModule], + declarations: [MockDirective(TranslateDirective)], + providers: [ + { provide: DataCleanupService, useValue: mockCleanupService }, + { provide: NgbActiveModal, useValue: mockActiveModal }, + { provide: TranslateService, useClass: MockTranslateService }, + ], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(CleanupOperationModalComponent); + comp = fixture.componentInstance; + cleanupService = TestBed.inject(DataCleanupService); + activeModal = TestBed.inject(NgbActiveModal); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize and fetch counts on init', () => { + const mockCounts: PlagiarismComparisonCleanupCountDTO = { + totalCount: 10, + plagiarismComparison: 5, + plagiarismElements: 3, + plagiarismSubmissions: 1, + plagiarismMatches: 1, + }; + jest.spyOn(cleanupService, 'countPlagiarismComparisons').mockReturnValue(of(new HttpResponse({ body: mockCounts }))); + + const operation = { + name: 'deletePlagiarismComparisons', + deleteFrom: dayjs().subtract(6, 'months'), + deleteTo: dayjs(), + lastExecuted: undefined, + datesValid: signal(true), + }; + fixture.componentRef.setInput('operation', operation); + fixture.detectChanges(); + + expect(cleanupService.countPlagiarismComparisons).toHaveBeenCalledOnce(); + expect(comp.counts).toEqual(mockCounts); + }); + + it('should execute cleanup operation successfully', () => { + const mockResponse: HttpResponse = new HttpResponse({ + body: { executionDate: dayjs(), jobType: 'deletePlagiarismComparisons' }, + }); + + const mockCounts: PlagiarismComparisonCleanupCountDTO = { + totalCount: 5, + plagiarismComparison: 2, + plagiarismElements: 1, + plagiarismSubmissions: 1, + plagiarismMatches: 1, + }; + jest.spyOn(cleanupService, 'deletePlagiarismComparisons').mockReturnValue(of(mockResponse)); + jest.spyOn(cleanupService, 'countPlagiarismComparisons').mockReturnValue(of(new HttpResponse({ body: mockCounts }))); + + const operation = { + name: 'deletePlagiarismComparisons', + deleteFrom: dayjs().subtract(6, 'months'), + deleteTo: dayjs(), + lastExecuted: undefined, + datesValid: signal(true), + }; + fixture.componentRef.setInput('operation', operation); + + comp.executeCleanupOperation(); + + expect(cleanupService.deletePlagiarismComparisons).toHaveBeenCalledOnce(); + expect(cleanupService.countPlagiarismComparisons).toHaveBeenCalledOnce(); + expect(comp.operationExecuted).toBeTrue(); + expect(comp.counts).toEqual(mockCounts); + }); + + it('should handle error during cleanup operation', () => { + const errorResponse = new HttpErrorResponse({ + status: 500, + statusText: 'Internal Server Error', + error: 'Some error message', + url: 'https://artemis.ase.in.tum.de/api/admin/plagiarism-comparisons', // Mock URL + }); + + jest.spyOn(cleanupService, 'deletePlagiarismComparisons').mockReturnValue(throwError(() => errorResponse)); + + let errorMessage: string | undefined; + comp.dialogError.subscribe((error) => { + errorMessage = error; + }); + + const operation = { + name: 'deletePlagiarismComparisons', + deleteFrom: dayjs().subtract(6, 'months'), + deleteTo: dayjs(), + lastExecuted: undefined, + datesValid: signal(true), + }; + fixture.componentRef.setInput('operation', operation); + + comp.executeCleanupOperation(); + + expect(cleanupService.deletePlagiarismComparisons).toHaveBeenCalledOnce(); + expect(errorMessage).toBe('Http failure response for https://artemis.ase.in.tum.de/api/admin/plagiarism-comparisons: 500 Internal Server Error'); + }); + + it('should close the modal', () => { + comp.close(); + + expect(activeModal.close).toHaveBeenCalledOnce(); + }); + + it('should compute hasEntriesToDelete correctly', () => { + let mockCounts: PlagiarismComparisonCleanupCountDTO = { totalCount: 0, plagiarismComparison: 0, plagiarismElements: 0, plagiarismSubmissions: 0, plagiarismMatches: 0 }; + comp.counts = mockCounts; + + expect(comp.hasEntriesToDelete).toBeFalse(); + + mockCounts = { totalCount: 5, plagiarismComparison: 2, plagiarismElements: 1, plagiarismSubmissions: 1, plagiarismMatches: 1 }; + comp.counts = mockCounts; + + expect(comp.hasEntriesToDelete).toBeTrue(); + }); +}); diff --git a/src/test/javascript/spec/component/admin/cleanup-service/cleanup-service.component.spec.ts b/src/test/javascript/spec/component/admin/cleanup-service/cleanup-service.component.spec.ts index 271315ee64a0..22d1de73d878 100644 --- a/src/test/javascript/spec/component/admin/cleanup-service/cleanup-service.component.spec.ts +++ b/src/test/javascript/spec/component/admin/cleanup-service/cleanup-service.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { of, throwError } from 'rxjs'; -import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { of } from 'rxjs'; +import { HttpResponse } from '@angular/common/http'; import dayjs from 'dayjs/esm'; import { ArtemisTestModule } from '../../../test.module'; @@ -17,24 +17,16 @@ describe('CleanupServiceComponent', () => { beforeEach(() => { const mockCleanupService = { getLastExecutions: jest.fn(), - deleteOrphans: jest.fn(), - deletePlagiarismComparisons: jest.fn(), - deleteNonRatedResults: jest.fn(), - deleteOldRatedResults: jest.fn(), - deleteOldSubmissionVersions: jest.fn(), - deleteOldFeedback: jest.fn(), }; TestBed.configureTestingModule({ imports: [ArtemisTestModule, CleanupServiceComponent], providers: [{ provide: DataCleanupService, useValue: mockCleanupService }], - }) - .compileComponents() - .then(() => { - fixture = TestBed.createComponent(CleanupServiceComponent); - comp = fixture.componentInstance; - cleanupService = TestBed.inject(DataCleanupService); - }); + }).compileComponents(); + + fixture = TestBed.createComponent(CleanupServiceComponent); + comp = fixture.componentInstance; + cleanupService = TestBed.inject(DataCleanupService); }); it('should load last executions on init', () => { @@ -51,42 +43,6 @@ describe('CleanupServiceComponent', () => { expect(comp.cleanupOperations[0].lastExecuted).toEqual(dayjs(executionRecord[0].executionDate)); }); - it('should execute a cleanup operation successfully', () => { - const operation = comp.cleanupOperations[0]; - const response = new HttpResponse({ - body: { executionDate: dayjs(), jobType: 'deleteOrphans' }, - }); - - jest.spyOn(cleanupService, 'deleteOrphans').mockReturnValue(of(response)); - - comp.executeCleanupOperation(operation); - - expect(cleanupService.deleteOrphans).toHaveBeenCalledOnce(); - expect(operation.lastExecuted).toEqual(dayjs(response.body!.executionDate)); - }); - - it('should handle error when executing cleanup operation', () => { - const operation = comp.cleanupOperations[0]; - const errorResponse = new HttpErrorResponse({ - status: 500, - statusText: 'Internal Server Error', - error: 'Some error message', - url: 'https://artemis.ase.in.tum.de/api/admin/orphans', // Adding a mock URL - }); - - jest.spyOn(cleanupService, 'deleteOrphans').mockReturnValue(throwError(() => errorResponse)); - - let errorMessage: string | undefined; - comp.dialogError.subscribe((error) => { - errorMessage = error; - }); - - comp.executeCleanupOperation(operation); - - expect(cleanupService.deleteOrphans).toHaveBeenCalledOnce(); - expect(errorMessage).toBe('Http failure response for https://artemis.ase.in.tum.de/api/admin/orphans: 500 Internal Server Error'); - }); - it('should validate date ranges correctly', () => { const validOperation: CleanupOperation = { name: 'deleteOrphans', diff --git a/src/test/javascript/spec/component/admin/cleanup-service/cleanup-service.service.spec.ts b/src/test/javascript/spec/component/admin/cleanup-service/cleanup-service.service.spec.ts index f64609872275..eafad72faed6 100644 --- a/src/test/javascript/spec/component/admin/cleanup-service/cleanup-service.service.spec.ts +++ b/src/test/javascript/spec/component/admin/cleanup-service/cleanup-service.service.spec.ts @@ -1,7 +1,15 @@ import { TestBed } from '@angular/core/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import dayjs from 'dayjs/esm'; -import { CleanupServiceExecutionRecordDTO, DataCleanupService } from 'app/admin/cleanup-service/data-cleanup.service'; +import { + CleanupServiceExecutionRecordDTO, + DataCleanupService, + NonLatestNonRatedResultsCleanupCountDTO, + NonLatestRatedResultsCleanupCountDTO, + OrphanCleanupCountDTO, + PlagiarismComparisonCleanupCountDTO, + SubmissionVersionsCleanupCountDTO, +} from 'app/admin/cleanup-service/data-cleanup.service'; import { provideHttpClient } from '@angular/common/http'; describe('DataCleanupService', () => { @@ -10,6 +18,46 @@ describe('DataCleanupService', () => { const mockDate = dayjs(); const mockExecutionRecord: CleanupServiceExecutionRecordDTO = { executionDate: mockDate, jobType: 'deleteOrphans' }; + const mockOrphanCount: OrphanCleanupCountDTO = { + totalCount: 10, + orphanFeedback: 2, + orphanLongFeedbackText: 3, + orphanTextBlock: 1, + orphanStudentScore: 1, + orphanTeamScore: 1, + orphanFeedbackForOrphanResults: 0, + orphanLongFeedbackTextForOrphanResults: 0, + orphanTextBlockForOrphanResults: 0, + orphanRating: 2, + orphanResultsWithoutParticipation: 0, + }; + + const mockPlagiarismCount: PlagiarismComparisonCleanupCountDTO = { + totalCount: 5, + plagiarismComparison: 3, + plagiarismElements: 1, + plagiarismSubmissions: 1, + plagiarismMatches: 0, + }; + + const mockNonRatedResultsCount: NonLatestNonRatedResultsCleanupCountDTO = { + totalCount: 4, + longFeedbackText: 1, + textBlock: 2, + feedback: 1, + }; + + const mockRatedResultsCount: NonLatestRatedResultsCleanupCountDTO = { + totalCount: 7, + longFeedbackText: 2, + textBlock: 3, + feedback: 2, + }; + + const mockSubmissionVersionsCount: SubmissionVersionsCleanupCountDTO = { + totalCount: 8, + submissionVersions: 8, + }; beforeEach(() => { TestBed.configureTestingModule({ @@ -103,4 +151,109 @@ describe('DataCleanupService', () => { expect(req.request.method).toBe('GET'); req.flush(mockExecutionRecords); }); + + it('should send GET request to count orphans', () => { + service.countOrphans().subscribe((res) => { + expect(res.body).toEqual(mockOrphanCount); + }); + + const req = httpMock.expectOne({ method: 'GET', url: 'api/admin/cleanup/orphans/count' }); + expect(req.request.method).toBe('GET'); + req.flush(mockOrphanCount); + }); + + it('should send GET request to count plagiarism comparisons with date range', () => { + const deleteFrom = '2024-03-07T13:06:36.100Z'; + const deleteTo = '2024-03-08T13:06:36.100Z'; + + service.countPlagiarismComparisons(dayjs(deleteFrom), dayjs(deleteTo)).subscribe((res) => { + expect(res.body).toEqual(mockPlagiarismCount); + }); + + const req = httpMock.expectOne({ + method: 'GET', + url: `api/admin/cleanup/plagiarism-comparisons/count?deleteFrom=${deleteFrom}&deleteTo=${deleteTo}`, + }); + + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('deleteFrom')).toBe(deleteFrom); + expect(req.request.params.get('deleteTo')).toBe(deleteTo); + req.flush(mockPlagiarismCount); + }); + + it('should send GET request to count non-rated results with date range', () => { + const deleteFrom = '2024-03-07T13:06:36.100Z'; + const deleteTo = '2024-03-08T13:06:36.100Z'; + + service.countNonRatedResults(dayjs(deleteFrom), dayjs(deleteTo)).subscribe((res) => { + expect(res.body).toEqual(mockNonRatedResultsCount); + }); + + const req = httpMock.expectOne({ + method: 'GET', + url: `api/admin/cleanup/non-rated-results/count?deleteFrom=${deleteFrom}&deleteTo=${deleteTo}`, + }); + + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('deleteFrom')).toBe(deleteFrom); + expect(req.request.params.get('deleteTo')).toBe(deleteTo); + req.flush(mockNonRatedResultsCount); + }); + + it('should send GET request to count old rated results with date range', () => { + const deleteFrom = '2024-03-07T13:06:36.100Z'; + const deleteTo = '2024-03-08T13:06:36.100Z'; + + service.countOldRatedResults(dayjs(deleteFrom), dayjs(deleteTo)).subscribe((res) => { + expect(res.body).toEqual(mockRatedResultsCount); + }); + + const req = httpMock.expectOne({ + method: 'GET', + url: `api/admin/cleanup/old-rated-results/count?deleteFrom=${deleteFrom}&deleteTo=${deleteTo}`, + }); + + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('deleteFrom')).toBe(deleteFrom); + expect(req.request.params.get('deleteTo')).toBe(deleteTo); + req.flush(mockRatedResultsCount); + }); + + it('should send DELETE request to delete old submission versions with date range', () => { + const deleteFrom = '2024-03-07T13:06:36.100Z'; + const deleteTo = '2024-03-08T13:06:36.100Z'; + + service.deleteOldSubmissionVersions(dayjs(deleteFrom), dayjs(deleteTo)).subscribe((res) => { + expect(res.body).toEqual(mockExecutionRecord); + }); + + const req = httpMock.expectOne({ + method: 'DELETE', + url: `api/admin/cleanup/old-submission-versions?deleteFrom=${deleteFrom}&deleteTo=${deleteTo}`, + }); + + expect(req.request.method).toBe('DELETE'); + expect(req.request.params.get('deleteFrom')).toBe(deleteFrom); + expect(req.request.params.get('deleteTo')).toBe(deleteTo); + req.flush(mockExecutionRecord); + }); + + it('should send GET request to count old submission versions with date range', () => { + const deleteFrom = '2024-03-07T13:06:36.100Z'; + const deleteTo = '2024-03-08T13:06:36.100Z'; + + service.countOldSubmissionVersions(dayjs(deleteFrom), dayjs(deleteTo)).subscribe((res) => { + expect(res.body).toEqual(mockSubmissionVersionsCount); + }); + + const req = httpMock.expectOne({ + method: 'GET', + url: `api/admin/cleanup/old-submission-versions/count?deleteFrom=${deleteFrom}&deleteTo=${deleteTo}`, + }); + + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('deleteFrom')).toBe(deleteFrom); + expect(req.request.params.get('deleteTo')).toBe(deleteTo); + req.flush(mockSubmissionVersionsCount); + }); }); From 943671783791e3446db46c8d3a60757a64638299 Mon Sep 17 00:00:00 2001 From: Michal Kawka <73854755+coolchock@users.noreply.github.com> Date: Fri, 20 Dec 2024 16:55:50 +0100 Subject: [PATCH 51/53] Development: Migrate suspicious behavior module to new client coding guidelines (#9887) --- .../app/exam/manage/exam-management.module.ts | 8 ---- .../plagiarism-cases-overview.component.html | 8 ++-- .../plagiarism-cases-overview.component.ts | 26 +++++----- .../suspicious-behavior.component.ts | 26 +++++----- .../suspicious-sessions-overview.component.ts | 5 ++ .../suspicious-sessions.service.ts | 4 +- .../suspicious-sessions.component.html | 2 +- .../suspicious-sessions.component.ts | 9 ++-- ...lagiarism-cases-overview.component.spec.ts | 47 +++++++++---------- .../suspicious-behavior.component.spec.ts | 10 +--- .../suspicious-sessions.component.spec.ts | 11 ++--- 11 files changed, 75 insertions(+), 81 deletions(-) diff --git a/src/main/webapp/app/exam/manage/exam-management.module.ts b/src/main/webapp/app/exam/manage/exam-management.module.ts index ddc813cc21f6..42b34266ba2b 100644 --- a/src/main/webapp/app/exam/manage/exam-management.module.ts +++ b/src/main/webapp/app/exam/manage/exam-management.module.ts @@ -55,10 +55,6 @@ import { ArtemisModePickerModule } from 'app/exercises/shared/mode-picker/mode-p import { StudentExamTimelineComponent } from './student-exams/student-exam-timeline/student-exam-timeline.component'; import { TitleChannelNameModule } from 'app/shared/form/title-channel-name/title-channel-name.module'; import { ExamEditWorkingTimeDialogComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time-dialog.component'; -import { SuspiciousBehaviorComponent } from './suspicious-behavior/suspicious-behavior.component'; -import { SuspiciousSessionsOverviewComponent } from './suspicious-behavior/suspicious-sessions-overview/suspicious-sessions-overview.component'; -import { PlagiarismCasesOverviewComponent } from './suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component'; -import { SuspiciousSessionsComponent } from './suspicious-behavior/suspicious-sessions/suspicious-sessions.component'; import { ExamEditWorkingTimeComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time.component'; import { ExamLiveAnnouncementCreateModalComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-announcement-dialog/exam-live-announcement-create-modal.component'; import { ExamLiveAnnouncementCreateButtonComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-announcement-dialog/exam-live-announcement-create-button.component'; @@ -145,10 +141,6 @@ const ENTITY_STATES = [...examManagementState]; ExamEditWorkingTimeDialogComponent, ExamLiveAnnouncementCreateModalComponent, ExamLiveAnnouncementCreateButtonComponent, - SuspiciousBehaviorComponent, - SuspiciousSessionsOverviewComponent, - PlagiarismCasesOverviewComponent, - SuspiciousSessionsComponent, StudentExamTimelineComponent, ProgrammingExerciseExamDiffComponent, ], diff --git a/src/main/webapp/app/exam/manage/suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component.html b/src/main/webapp/app/exam/manage/suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component.html index 88b2917e7530..fa09f1ed4c47 100644 --- a/src/main/webapp/app/exam/manage/suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component.html +++ b/src/main/webapp/app/exam/manage/suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component.html @@ -10,12 +10,12 @@
- @for (exercise of exercises; track exercise; let i = $index) { + @for (exercise of exercises(); track exercise; let i = $index) { {{ i + 1 }} {{ exercise.title }} - {{ plagiarismResultsPerExercise.get(exercise) }} - {{ plagiarismCasesPerExercise.get(exercise) }} + {{ plagiarismResultsPerExercise().get(exercise) }} + {{ plagiarismCasesPerExercise().get(exercise) }}

} -@if (anyPlagiarismCases) { +@if (anyPlagiarismCases()) {