From e22ec06c11f1275cb4b3c41e605ed38778502e65 Mon Sep 17 00:00:00 2001 From: Julian Christl Date: Sun, 24 Sep 2023 16:51:09 +0200 Subject: [PATCH] Squash all already approved changes --- .../domain/quiz/DragAndDropQuestion.java | 85 ++- .../in/www1/artemis/domain/quiz/DragItem.java | 72 +-- .../artemis/domain/quiz/QuizExercise.java | 2 +- .../repository/QuizExerciseRepository.java | 5 + .../service/AttachmentUnitService.java | 2 +- .../service/FileUploadSubmissionService.java | 2 +- .../artemis/service/LectureImportService.java | 4 +- .../service/QuizExerciseImportService.java | 155 +++--- .../artemis/service/QuizExerciseService.java | 203 +++++++- .../service/exam/ExamImportService.java | 48 +- .../artemis/web/rest/AttachmentResource.java | 12 +- .../www1/artemis/web/rest/ExamResource.java | 3 +- .../web/rest/ExerciseGroupResource.java | 4 +- .../web/rest/QuizExerciseResource.java | 116 +++-- .../quiz-exercise-generator.ts | 36 +- ...drag-and-drop-question-edit.component.html | 64 +-- .../drag-and-drop-question-edit.component.ts | 237 ++++----- .../quiz-exercise-detail.component.html | 2 +- .../manage/quiz-exercise-detail.component.ts | 15 +- .../manage/quiz-exercise-popup.service.ts | 7 +- .../quiz/manage/quiz-exercise.service.ts | 38 +- ...z-question-list-edit-existing.component.ts | 36 +- .../quiz-question-list-edit.component.html | 5 + .../quiz-question-list-edit.component.ts | 34 +- ...aluate-drag-and-drop-question.component.ts | 35 +- .../quiz-re-evaluate-warning.component.ts | 4 +- .../re-evaluate/quiz-re-evaluate.component.ts | 14 +- .../re-evaluate/quiz-re-evaluate.service.ts | 14 +- .../app/shared/http/file-uploader.service.ts | 16 - .../webapp/app/shared/http/file.service.ts | 36 ++ .../webapp/i18n/de/dragAndDropQuestion.json | 2 +- .../webapp/i18n/en/dragAndDropQuestion.json | 2 +- .../cypress/e2e/course/CourseExercise.cy.ts | 6 +- .../e2e/exercises/ExerciseImport.cy.ts | 2 +- .../QuizExerciseAssessment.cy.ts | 5 +- .../QuizExerciseDropLocation.cy.ts | 1 + .../QuizExerciseManagement.cy.ts | 4 +- .../QuizExerciseParticipation.cy.ts | 5 +- .../exam/ExamExerciseGroupCreationPage.ts | 10 +- .../exercises/text/TextEditorPage.ts | 2 +- .../support/requests/ExerciseAPIRequests.ts | 6 +- src/test/cypress/support/utils.ts | 9 +- .../FileUploadSubmissionIntegrationTest.java | 2 +- .../quizexercise/QuizExerciseFactory.java | 20 +- .../QuizExerciseIntegrationTest.java | 487 +++++++++++++----- .../QuizSubmissionIntegrationTest.java | 7 +- .../www1/artemis/util/RequestUtilService.java | 4 +- .../quiz-exercise-generator.spec.ts | 2 - ...g-and-drop-question-edit.component.spec.ts | 275 +++++----- ...stion-list-edit-existing.component.spec.ts | 34 +- .../quiz-question-list-edit.component.spec.ts | 35 ++ ...e-drag-and-drop-question.component.spec.ts | 36 +- .../file-upload-submission.component.spec.ts | 4 - .../quiz-exercise-detail.component.spec.ts | 75 ++- .../shared/http/file.service.spec.ts | 79 +++ .../service/quiz-exercise.service.spec.ts | 59 ++- .../service/quiz-re-evaluate.service.spec.ts | 43 ++ .../modeling/test-models/class-diagram.json | 2 +- 58 files changed, 1681 insertions(+), 843 deletions(-) create mode 100644 src/test/javascript/spec/component/shared/http/file.service.spec.ts create mode 100644 src/test/javascript/spec/service/quiz-re-evaluate.service.spec.ts diff --git a/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragAndDropQuestion.java b/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragAndDropQuestion.java index acd0e7ce53fc..a8c8e3ba5de8 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragAndDropQuestion.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragAndDropQuestion.java @@ -1,12 +1,15 @@ package de.tum.in.www1.artemis.domain.quiz; -import java.nio.file.Path; +import java.net.URI; import java.util.*; import javax.persistence.*; +import org.apache.commons.lang3.StringUtils; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; @@ -15,7 +18,7 @@ import de.tum.in.www1.artemis.config.Constants; import de.tum.in.www1.artemis.domain.quiz.scoring.*; import de.tum.in.www1.artemis.domain.view.QuizView; -import de.tum.in.www1.artemis.service.EntityFileService; +import de.tum.in.www1.artemis.exception.FilePathParsingException; import de.tum.in.www1.artemis.service.FilePathService; import de.tum.in.www1.artemis.service.FileService; @@ -28,16 +31,13 @@ public class DragAndDropQuestion extends QuizQuestion { @Transient - private final transient FilePathService filePathService = new FilePathService(); + private final transient Logger log = LoggerFactory.getLogger(DragAndDropQuestion.class); @Transient - private final transient FileService fileService = new FileService(); - - @Transient - private final transient EntityFileService entityFileService = new EntityFileService(fileService, filePathService); + private final transient FilePathService filePathService = new FilePathService(); @Transient - private String prevBackgroundFilePath; + private final transient FileService fileService = new FileService(); @Column(name = "background_file_path") @JsonView(QuizView.Before.class) @@ -139,42 +139,23 @@ public Boolean isValid() { return false; } + // A drag item can either be a text or a picture, but not both or none + for (DragItem dragItem : dragItems) { + if (StringUtils.isEmpty(dragItem.getText()) == StringUtils.isEmpty(dragItem.getPictureFilePath())) { + return false; + } + } + // check if at least one correct mapping exists return getCorrectMappings() != null && !getCorrectMappings().isEmpty(); - // TODO (?): Add checks for "is solvable" and "no misleading correct mapping" --> look at the implementation in the client + // TODO: (?) Add checks for "is solvable" and "no misleading correct mapping" --> look at the implementation in the client } - /* - * NOTE: The file management is necessary to differentiate between temporary and used files and to delete used files when the corresponding question is deleted or it is - * replaced by another file. The workflow is as follows 1. user uploads a file -> this is a temporary file, because at this point the corresponding question might not exist - * yet. 2. user saves the question -> now we move the temporary file which is addressed in backgroundFilePath to a permanent location and update the value in backgroundFilePath - * accordingly. => This happens in @PrePersist and @PostPersist 3. user might upload another file to replace the existing file -> this new file is a temporary file at first 4. - * user saves changes (with the new backgroundFilePath pointing to the new temporary file) -> now we delete the old file in the permanent location and move the new file to a - * permanent location and update the value in backgroundFilePath accordingly. => This happens in @PreUpdate and uses @PostLoad to know the old path 5. When question is deleted, - * the file in the permanent location is deleted => This happens in @PostRemove - */ - /** - * Initialisation of the DragAndDropQuestion on Server start + * This method is called after the entity is saved for the first time. We replace the placeholder in the backgroundFilePath with the id of the entity because we don't know it + * before creation. */ - @PostLoad - public void onLoad() { - // replace placeholder with actual id if necessary (this is needed because changes made in afterCreate() are not persisted) - if (backgroundFilePath != null && backgroundFilePath.contains(Constants.FILEPATH_ID_PLACEHOLDER)) { - backgroundFilePath = backgroundFilePath.replace(Constants.FILEPATH_ID_PLACEHOLDER, getId().toString()); - } - // save current path as old path (needed to know old path in onUpdate() and onDelete()) - prevBackgroundFilePath = backgroundFilePath; - } - - @PrePersist - public void beforeCreate() { - if (backgroundFilePath != null) { - backgroundFilePath = entityFileService.moveTempFileBeforeEntityPersistence(backgroundFilePath, FilePathService.getDragAndDropBackgroundFilePath(), false); - } - } - @PostPersist public void afterCreate() { // replace placeholder with actual id if necessary (id is no longer null at this point) @@ -183,16 +164,20 @@ public void afterCreate() { } } - @PreUpdate - public void onUpdate() { - backgroundFilePath = entityFileService.handlePotentialFileUpdateBeforeEntityPersistence(getId(), prevBackgroundFilePath, backgroundFilePath, - FilePathService.getDragAndDropBackgroundFilePath(), false); - } - + /** + * This method is called when deleting the entity. It makes sure that the corresponding file is deleted as well. + */ @PostRemove public void onDelete() { - if (prevBackgroundFilePath != null) { - fileService.schedulePathForDeletion(Path.of(prevBackgroundFilePath), 0); + // delete old file if necessary + try { + if (backgroundFilePath != null) { + fileService.schedulePathForDeletion(filePathService.actualPathForPublicPathOrThrow(URI.create(backgroundFilePath)), 0); + } + } + catch (FilePathParsingException e) { + // if the file path is invalid, we don't need to delete it + log.warn("Could not delete file with path {}. Assume already deleted, entity can be removed.", backgroundFilePath, e); } } @@ -219,7 +204,6 @@ public Set getCorrectDragItemsForDropLocation(DropLocation dropLocatio * @return the dragItem with the given ID, or null if the dragItem is not contained in this question */ public DragItem findDragItemById(Long dragItemId) { - if (dragItemId != null) { // iterate through all dragItems of this quiz for (DragItem dragItem : dragItems) { @@ -239,7 +223,6 @@ public DragItem findDragItemById(Long dragItemId) { * @return the dropLocation with the given ID, or null if the dropLocation is not contained in this question */ public DropLocation findDropLocationById(Long dropLocationId) { - if (dropLocationId != null) { // iterate through all dropLocations of this quiz for (DropLocation dropLocation : dropLocations) { @@ -259,6 +242,7 @@ public DropLocation findDropLocationById(Long dropLocationId) { */ public void undoUnallowedChanges(QuizQuestion originalQuizQuestion) { if (originalQuizQuestion instanceof DragAndDropQuestion dndOriginalQuestion) { + backgroundFilePath = dndOriginalQuestion.getBackgroundFilePath(); // undo unallowed dragItemChanges undoUnallowedDragItemChanges(dndOriginalQuestion); // undo unallowed dragItemChanges @@ -272,7 +256,6 @@ public void undoUnallowedChanges(QuizQuestion originalQuizQuestion) { * @param originalQuestion the original DragAndDrop-object, which will be compared with this question */ private void undoUnallowedDragItemChanges(DragAndDropQuestion originalQuestion) { - // find added DragItems, which are not allowed to be added Set notAllowedAddedDragItems = new HashSet<>(); // check every dragItem of the question @@ -281,6 +264,7 @@ private void undoUnallowedDragItemChanges(DragAndDropQuestion originalQuestion) if (originalQuestion.getDragItems().contains(dragItem)) { // find original dragItem DragItem originalDragItem = originalQuestion.findDragItemById(dragItem.getId()); + // correct invalid = null to invalid = false if (dragItem.isInvalid() == null) { dragItem.setInvalid(false); @@ -303,7 +287,6 @@ private void undoUnallowedDragItemChanges(DragAndDropQuestion originalQuestion) * @param originalQuestion the original DragAndDrop-object, which will be compared with this question */ private void undoUnallowedDropLocationChanges(DragAndDropQuestion originalQuestion) { - // find added DropLocations, which are not allowed to be added Set notAllowedAddedDropLocations = new HashSet<>(); // check every dropLocation of the question @@ -355,9 +338,7 @@ public void initializeStatistic() { * @return a boolean which is true if the dragItem-changes make an update necessary and false if not */ private boolean checkDragItemsIfRecalculationIsNecessary(DragAndDropQuestion originalQuestion) { - boolean updateNecessary = false; - // check every dragItem of the question for (DragItem dragItem : this.getDragItems()) { // check if the dragItem were already in the originalQuizExercise @@ -388,9 +369,7 @@ private boolean checkDragItemsIfRecalculationIsNecessary(DragAndDropQuestion ori * @return a boolean which is true if the dropLocation-changes make an update necessary and false if not */ private boolean checkDropLocationsIfRecalculationIsNecessary(DragAndDropQuestion originalQuestion) { - boolean updateNecessary = false; - // check every dropLocation of the question for (DropLocation dropLocation : this.getDropLocations()) { // check if the dropLocation were already in the originalQuizExercise diff --git a/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragItem.java b/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragItem.java index a328ee1704f5..a6de54e6dda4 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragItem.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/quiz/DragItem.java @@ -1,6 +1,6 @@ package de.tum.in.www1.artemis.domain.quiz; -import java.nio.file.Path; +import java.net.URI; import java.util.HashSet; import java.util.Set; @@ -8,6 +8,8 @@ import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; @@ -16,7 +18,7 @@ import de.tum.in.www1.artemis.config.Constants; import de.tum.in.www1.artemis.domain.TempIdObject; import de.tum.in.www1.artemis.domain.view.QuizView; -import de.tum.in.www1.artemis.service.EntityFileService; +import de.tum.in.www1.artemis.exception.FilePathParsingException; import de.tum.in.www1.artemis.service.FilePathService; import de.tum.in.www1.artemis.service.FileService; @@ -30,16 +32,13 @@ public class DragItem extends TempIdObject implements QuizQuestionComponent { @Transient - private final transient FilePathService filePathService = new FilePathService(); - - @Transient - private final transient FileService fileService = new FileService(); + private final transient Logger log = LoggerFactory.getLogger(DragItem.class); @Transient - private final transient EntityFileService entityFileService = new EntityFileService(fileService, filePathService); + private final transient FilePathService filePathService = new FilePathService(); @Transient - private String prevPictureFilePath; + private final transient FileService fileService = new FileService(); @Column(name = "picture_file_path") @JsonView(QuizView.Before.class) @@ -66,13 +65,13 @@ public String getPictureFilePath() { return pictureFilePath; } - public void setPictureFilePath(String pictureFilePath) { + public DragItem pictureFilePath(String pictureFilePath) { this.pictureFilePath = pictureFilePath; + return this; } - public DragItem pictureFilePath(String pictureFilePath) { + public void setPictureFilePath(String pictureFilePath) { this.pictureFilePath = pictureFilePath; - return this; } public String getText() { @@ -120,37 +119,10 @@ public DragItem removeMappings(DragAndDropMapping mapping) { return this; } - /* - * NOTE: The file management is necessary to differentiate between temporary and used files and to delete used files when the corresponding drag item is deleted or it is - * replaced by another file. The workflow is as follows 1. user uploads a file -> this is a temporary file, because at this point the corresponding drag item might not exist - * yet. 2. user saves the drag item -> now we move the temporary file which is addressed in pictureFilePath to a permanent location and update the value in pictureFilePath - * accordingly. => This happens in @PrePersist and @PostPersist 3. user might upload another file to replace the existing file -> this new file is a temporary file at first 4. - * user saves changes (with the new pictureFilePath pointing to the new temporary file) -> now we delete the old file in the permanent location and move the new file to a - * permanent location and update the value in pictureFilePath accordingly. => This happens in @PreUpdate and uses @PostLoad to know the old path 5. When drag item is deleted, - * the file in the permanent location is deleted => This happens in @PostRemove NOTE: Number 3 and 4 are not possible for drag items with the current UI, but might be possible - * in the future and are implemented here to prevent unexpected behaviour when UI changes and to keep code similar to DragAndDropQuestion.java - */ - /** - * Initialisation of the DragItem on Server start + * This method is called after the entity is saved for the first time. We replace the placeholder in the pictureFilePath with the id of the entity because we don't know it + * before creation. */ - @PostLoad - public void onLoad() { - // replace placeholder with actual id if necessary (this is needed because changes made in afterCreate() are not persisted) - if (pictureFilePath != null && pictureFilePath.contains(Constants.FILEPATH_ID_PLACEHOLDER)) { - pictureFilePath = pictureFilePath.replace(Constants.FILEPATH_ID_PLACEHOLDER, getId().toString()); - } - // save current path as old path (needed to know old path in onUpdate() and onDelete()) - prevPictureFilePath = pictureFilePath; - } - - @PrePersist - public void beforeCreate() { - if (pictureFilePath != null) { - pictureFilePath = entityFileService.moveTempFileBeforeEntityPersistence(pictureFilePath, FilePathService.getDragItemFilePath(), false); - } - } - @PostPersist public void afterCreate() { // replace placeholder with actual id if necessary (id is no longer null at this point) @@ -159,16 +131,20 @@ public void afterCreate() { } } - @PreUpdate - public void onUpdate() { - pictureFilePath = entityFileService.handlePotentialFileUpdateBeforeEntityPersistence(getId(), prevPictureFilePath, pictureFilePath, FilePathService.getDragItemFilePath(), - false); - } - + /** + * This method is called when deleting this entity. It makes sure that the corresponding file is deleted as well. + */ @PostRemove public void onDelete() { - if (prevPictureFilePath != null) { - fileService.schedulePathForDeletion(Path.of(prevPictureFilePath), 0); + // delete old file if necessary + try { + if (pictureFilePath != null) { + fileService.schedulePathForDeletion(filePathService.actualPathForPublicPathOrThrow(URI.create(pictureFilePath)), 0); + } + } + catch (FilePathParsingException e) { + // if the file path is invalid, we don't need to delete it + log.warn("Could not delete file with path {}. Assume already deleted, entity can be removed.", pictureFilePath, e); } } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizExercise.java b/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizExercise.java index cc5e9bf966c2..93b53d39039b 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizExercise.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/quiz/QuizExercise.java @@ -589,7 +589,7 @@ else if (batch != null && batch.isStarted()) { public void validateDates() { super.validateDates(); quizBatches.forEach(quizBatch -> { - if (quizBatch.getStartTime() != null && quizBatch.getStartTime().isBefore(getReleaseDate())) { + if (quizBatch.getStartTime() != null && getReleaseDate() != null && quizBatch.getStartTime().isBefore(getReleaseDate())) { throw new BadRequestAlertException("Start time must not be before release date!", getTitle(), "noValidDates"); } }); diff --git a/src/main/java/de/tum/in/www1/artemis/repository/QuizExerciseRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/QuizExerciseRepository.java index f83e507f6dfa..00dbd126a207 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/QuizExerciseRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/QuizExerciseRepository.java @@ -64,6 +64,11 @@ default QuizExercise findByIdElseThrow(Long quizExerciseId) throws EntityNotFoun return findById(quizExerciseId).orElseThrow(() -> new EntityNotFoundException("Quiz Exercise", quizExerciseId)); } + @NotNull + default QuizExercise findWithEagerQuestionsByIdOrElseThrow(Long quizExerciseId) { + return findWithEagerQuestionsById(quizExerciseId).orElseThrow(() -> new EntityNotFoundException("QuizExercise", quizExerciseId)); + }; + /** * Get one quiz exercise * diff --git a/src/main/java/de/tum/in/www1/artemis/service/AttachmentUnitService.java b/src/main/java/de/tum/in/www1/artemis/service/AttachmentUnitService.java index 103045c8daef..f211b13638c4 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/AttachmentUnitService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/AttachmentUnitService.java @@ -158,7 +158,7 @@ private void handleFile(MultipartFile file, Attachment attachment, boolean keepF */ private void evictCache(MultipartFile file, AttachmentUnit attachmentUnit) { if (file != null && !file.isEmpty()) { - this.cacheManager.getCache("files").evict(filePathService.actualPathForPublicPath(URI.create(attachmentUnit.getAttachment().getLink())).toString()); + this.cacheManager.getCache("files").evict(filePathService.actualPathForPublicPathOrThrow(URI.create(attachmentUnit.getAttachment().getLink())).toString()); } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/FileUploadSubmissionService.java b/src/main/java/de/tum/in/www1/artemis/service/FileUploadSubmissionService.java index df4454fddcc9..8003e89f7ed8 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/FileUploadSubmissionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/FileUploadSubmissionService.java @@ -156,7 +156,7 @@ private URI storeFile(FileUploadSubmission fileUploadSubmission, StudentParticip fileUploadSubmission = fileUploadSubmissionRepository.save(fileUploadSubmission); } final Path savePath = saveFileForSubmission(file, fileUploadSubmission, exercise); - final URI newFilePath = filePathService.publicPathForActualPath(savePath, fileUploadSubmission.getId()); + final URI newFilePath = filePathService.publicPathForActualPathOrThrow(savePath, fileUploadSubmission.getId()); // We need to ensure that we can access the store file and the stored file is the same as was passed to us in the request final var storedFileHash = DigestUtils.md5Hex(Files.newInputStream(savePath)); diff --git a/src/main/java/de/tum/in/www1/artemis/service/LectureImportService.java b/src/main/java/de/tum/in/www1/artemis/service/LectureImportService.java index 203f8ea870c9..f352ba350532 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/LectureImportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/LectureImportService.java @@ -153,7 +153,7 @@ private Attachment cloneAttachment(final Attachment importedAttachment) { attachment.setVersion(importedAttachment.getVersion()); attachment.setAttachmentType(importedAttachment.getAttachmentType()); - Path oldPath = filePathService.actualPathForPublicPath(URI.create(importedAttachment.getLink())); + Path oldPath = filePathService.actualPathForPublicPathOrThrow(URI.create(importedAttachment.getLink())); Path tempPath = FilePathService.getTempFilePath().resolve(oldPath.getFileName()); try { @@ -161,7 +161,7 @@ private Attachment cloneAttachment(final Attachment importedAttachment) { FileUtils.copyFile(oldPath.toFile(), tempPath.toFile(), REPLACE_EXISTING); // File was copied to a temp directory and will be moved once we persist the attachment - attachment.setLink(filePathService.publicPathForActualPath(tempPath, null).toString()); + attachment.setLink(filePathService.publicPathForActualPathOrThrow(tempPath, null).toString()); } catch (IOException e) { log.error("Error while copying file", e); diff --git a/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseImportService.java b/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseImportService.java index 29638f7d66b5..55f12a4e8ae2 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseImportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseImportService.java @@ -1,14 +1,18 @@ package de.tum.in.www1.artemis.service; +import java.io.IOException; import java.net.URI; +import java.nio.file.Files; import java.nio.file.Path; import java.util.*; +import java.util.stream.Collectors; import javax.validation.constraints.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import de.tum.in.www1.artemis.domain.quiz.*; import de.tum.in.www1.artemis.repository.ExampleSubmissionRepository; @@ -43,17 +47,18 @@ public QuizExerciseImportService(QuizExerciseService quizExerciseService, FileSe * Imports a quiz exercise creating a new entity, copying all basic values and saving it in the database. * All basic include everything except Student-, Tutor participations, and student questions.
* This method calls {@link #copyQuizExerciseBasis(QuizExercise)} to set up the basis of the exercise and - * {@link #copyQuizQuestions(QuizExercise, QuizExercise)} for a hard copy of the questions. + * {@link #copyQuizQuestions(QuizExercise, QuizExercise, List)} for a hard copy of the questions. * * @param templateExercise The template exercise which should get imported * @param importedExercise The new exercise already containing values which should not get copied, i.e. overwritten + * @param providedFiles The files that were provided by the user * @return The newly created exercise */ @NotNull - public QuizExercise importQuizExercise(final QuizExercise templateExercise, QuizExercise importedExercise) { + public QuizExercise importQuizExercise(final QuizExercise templateExercise, QuizExercise importedExercise, @NotNull List providedFiles) throws IOException { log.debug("Creating a new Exercise based on exercise {}", templateExercise); QuizExercise newExercise = copyQuizExerciseBasis(importedExercise); - copyQuizQuestions(importedExercise, newExercise); + copyQuizQuestions(importedExercise, newExercise, providedFiles); copyQuizBatches(importedExercise, newExercise); QuizExercise newQuizExercise = quizExerciseService.save(newExercise); @@ -89,80 +94,110 @@ private QuizExercise copyQuizExerciseBasis(QuizExercise importedExercise) { * * @param importedExercise The exercise from which to copy the questions * @param newExercise The exercise to which the questions are copied + * @param providedFiles The files that were provided by the user */ - private void copyQuizQuestions(QuizExercise importedExercise, QuizExercise newExercise) { + private void copyQuizQuestions(QuizExercise importedExercise, QuizExercise newExercise, @NotNull List providedFiles) throws IOException { log.debug("Copying the QuizQuestions to new QuizExercise: {}", newExercise); + // Setup file map + Map fileMap = providedFiles.stream().collect(Collectors.toMap(MultipartFile::getOriginalFilename, file -> file)); + for (QuizQuestion quizQuestion : importedExercise.getQuizQuestions()) { quizQuestion.setId(null); quizQuestion.setQuizQuestionStatistic(null); if (quizQuestion instanceof MultipleChoiceQuestion mcQuestion) { - for (AnswerOption answerOption : mcQuestion.getAnswerOptions()) { - answerOption.setId(null); - answerOption.setQuestion(mcQuestion); - } + setUpMultipleChoiceQuestionForImport(mcQuestion); } else if (quizQuestion instanceof DragAndDropQuestion dndQuestion) { - if (dndQuestion.getBackgroundFilePath() != null) { - // Need to copy the file and get a new path, otherwise two different questions would share the same image and would cause problems in case one was deleted - Path oldPath = filePathService.actualPathForPublicPath(URI.create(dndQuestion.getBackgroundFilePath())); - Path newPath = fileService.copyExistingFileToTarget(oldPath, FilePathService.getDragAndDropBackgroundFilePath()); - dndQuestion.setBackgroundFilePath(filePathService.publicPathForActualPath(newPath, null).toString()); - } - else { - log.warn("BackgroundFilePath of DragAndDropQuestion {} is null", dndQuestion.getId()); - } - - for (DropLocation dropLocation : dndQuestion.getDropLocations()) { - dropLocation.setId(null); - dropLocation.setQuestion(dndQuestion); - } - for (DragItem dragItem : dndQuestion.getDragItems()) { - dragItem.setId(null); - dragItem.setQuestion(dndQuestion); - if (dragItem.getPictureFilePath() != null) { - // Need to copy the file and get a new path, same as above - Path oldDragItemPath = filePathService.actualPathForPublicPath(URI.create(dragItem.getPictureFilePath())); - Path newDragItemPath = fileService.copyExistingFileToTarget(oldDragItemPath, FilePathService.getDragItemFilePath()); - dragItem.setPictureFilePath(filePathService.publicPathForActualPath(newDragItemPath, null).toString()); - } - } - for (DragAndDropMapping dragAndDropMapping : dndQuestion.getCorrectMappings()) { - dragAndDropMapping.setId(null); - dragAndDropMapping.setQuestion(dndQuestion); - if (dragAndDropMapping.getDragItemIndex() != null) { - dragAndDropMapping.setDragItem(dndQuestion.getDragItems().get(dragAndDropMapping.getDragItemIndex())); - } - if (dragAndDropMapping.getDropLocationIndex() != null) { - dragAndDropMapping.setDropLocation(dndQuestion.getDropLocations().get(dragAndDropMapping.getDropLocationIndex())); - } - } + setUpDragAndDropQuestionForImport(dndQuestion, fileMap); } else if (quizQuestion instanceof ShortAnswerQuestion saQuestion) { - for (ShortAnswerSpot shortAnswerSpot : saQuestion.getSpots()) { - shortAnswerSpot.setId(null); - shortAnswerSpot.setQuestion(saQuestion); - } - for (ShortAnswerSolution shortAnswerSolution : saQuestion.getSolutions()) { - shortAnswerSolution.setId(null); - shortAnswerSolution.setQuestion(saQuestion); - } - for (ShortAnswerMapping shortAnswerMapping : saQuestion.getCorrectMappings()) { - shortAnswerMapping.setId(null); - shortAnswerMapping.setQuestion(saQuestion); - if (shortAnswerMapping.getShortAnswerSolutionIndex() != null) { - shortAnswerMapping.setSolution(saQuestion.getSolutions().get(shortAnswerMapping.getShortAnswerSolutionIndex())); - } - if (shortAnswerMapping.getShortAnswerSpotIndex() != null) { - shortAnswerMapping.setSpot(saQuestion.getSpots().get(shortAnswerMapping.getShortAnswerSpotIndex())); - } - } + setUpShortAnswerQuestionForImport(saQuestion); } quizQuestion.setExercise(newExercise); } newExercise.setQuizQuestions(importedExercise.getQuizQuestions()); } + private void setUpMultipleChoiceQuestionForImport(MultipleChoiceQuestion mcQuestion) { + for (AnswerOption answerOption : mcQuestion.getAnswerOptions()) { + answerOption.setId(null); + answerOption.setQuestion(mcQuestion); + } + } + + private void setUpDragAndDropQuestionForImport(DragAndDropQuestion dndQuestion, Map fileMap) throws IOException { + if (dndQuestion.getBackgroundFilePath() != null) { + // Need to copy the file and get a new path, otherwise two different questions would share the same image and would cause problems in case one was deleted + Path oldPath = filePathService.actualPathForPublicPath(URI.create(dndQuestion.getBackgroundFilePath())); + if (oldPath != null && Files.exists(oldPath)) { + // Copy the file to the new path + Path newPath = fileService.copyExistingFileToTarget(oldPath, FilePathService.getDragAndDropBackgroundFilePath()); + dndQuestion.setBackgroundFilePath(filePathService.publicPathForActualPath(newPath, null).toString()); + } + else { + // A new file got uploaded, everything got already verified at this point + quizExerciseService.saveDndQuestionBackground(dndQuestion, fileMap, null); + } + } + + for (DropLocation dropLocation : dndQuestion.getDropLocations()) { + dropLocation.setId(null); + dropLocation.setQuestion(dndQuestion); + } + + for (DragItem dragItem : dndQuestion.getDragItems()) { + dragItem.setId(null); + dragItem.setQuestion(dndQuestion); + if (dragItem.getPictureFilePath() == null) { + continue; + } + + Path oldDragItemPath = filePathService.actualPathForPublicPath(URI.create(dragItem.getPictureFilePath())); + if (oldDragItemPath != null && Files.exists(oldDragItemPath)) { + // Need to copy the file and get a new path, same as above + Path newDragItemPath = fileService.copyExistingFileToTarget(oldDragItemPath, FilePathService.getDragItemFilePath()); + dragItem.setPictureFilePath( + filePathService.publicPathForActualPath(fileService.copyExistingFileToTarget(newDragItemPath, FilePathService.getDragItemFilePath()), null).toString()); + } + else { + // A new file got uploaded, everything got already verified at this point + quizExerciseService.saveDndDragItemPicture(dragItem, fileMap, null); + } + } + for (DragAndDropMapping dragAndDropMapping : dndQuestion.getCorrectMappings()) { + dragAndDropMapping.setId(null); + dragAndDropMapping.setQuestion(dndQuestion); + if (dragAndDropMapping.getDragItemIndex() != null) { + dragAndDropMapping.setDragItem(dndQuestion.getDragItems().get(dragAndDropMapping.getDragItemIndex())); + } + if (dragAndDropMapping.getDropLocationIndex() != null) { + dragAndDropMapping.setDropLocation(dndQuestion.getDropLocations().get(dragAndDropMapping.getDropLocationIndex())); + } + } + } + + private void setUpShortAnswerQuestionForImport(ShortAnswerQuestion saQuestion) { + for (ShortAnswerSpot shortAnswerSpot : saQuestion.getSpots()) { + shortAnswerSpot.setId(null); + shortAnswerSpot.setQuestion(saQuestion); + } + for (ShortAnswerSolution shortAnswerSolution : saQuestion.getSolutions()) { + shortAnswerSolution.setId(null); + shortAnswerSolution.setQuestion(saQuestion); + } + for (ShortAnswerMapping shortAnswerMapping : saQuestion.getCorrectMappings()) { + shortAnswerMapping.setId(null); + shortAnswerMapping.setQuestion(saQuestion); + if (shortAnswerMapping.getShortAnswerSolutionIndex() != null) { + shortAnswerMapping.setSolution(saQuestion.getSolutions().get(shortAnswerMapping.getShortAnswerSolutionIndex())); + } + if (shortAnswerMapping.getShortAnswerSpotIndex() != null) { + shortAnswerMapping.setSpot(saQuestion.getSpots().get(shortAnswerMapping.getShortAnswerSpotIndex())); + } + } + } + /** * This helper method copies all batches of the {@code importedExercise} into a new exercise. * diff --git a/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseService.java b/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseService.java index 5bb2a3c27ca6..8751ca8fa050 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/QuizExerciseService.java @@ -1,14 +1,25 @@ package de.tum.in.www1.artemis.service; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.*; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.commons.io.FilenameUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import de.tum.in.www1.artemis.config.Constants; import de.tum.in.www1.artemis.domain.Result; @@ -16,15 +27,19 @@ import de.tum.in.www1.artemis.domain.enumeration.QuizMode; import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.domain.quiz.*; +import de.tum.in.www1.artemis.exception.FilePathParsingException; import de.tum.in.www1.artemis.repository.*; import de.tum.in.www1.artemis.service.scheduled.cache.quiz.QuizScheduleService; import de.tum.in.www1.artemis.web.rest.dto.PageableSearchDTO; import de.tum.in.www1.artemis.web.rest.dto.SearchResultPageDTO; +import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; import de.tum.in.www1.artemis.web.rest.util.PageUtil; @Service public class QuizExerciseService extends QuizService { + public static final String ENTITY_NAME = "QuizExercise"; + private final Logger log = LoggerFactory.getLogger(QuizExerciseService.class); private final QuizExerciseRepository quizExerciseRepository; @@ -41,9 +56,12 @@ public class QuizExerciseService extends QuizService { private final ExerciseSpecificationService exerciseSpecificationService; - public QuizExerciseService(QuizExerciseRepository quizExerciseRepository, DragAndDropMappingRepository dragAndDropMappingRepository, ResultRepository resultRepository, - ShortAnswerMappingRepository shortAnswerMappingRepository, QuizSubmissionRepository quizSubmissionRepository, QuizScheduleService quizScheduleService, - QuizStatisticService quizStatisticService, QuizBatchService quizBatchService, ExerciseSpecificationService exerciseSpecificationService) { + private final FileService fileService; + + public QuizExerciseService(QuizExerciseRepository quizExerciseRepository, ResultRepository resultRepository, QuizSubmissionRepository quizSubmissionRepository, + QuizScheduleService quizScheduleService, QuizStatisticService quizStatisticService, QuizBatchService quizBatchService, + ExerciseSpecificationService exerciseSpecificationService, FileService fileService, DragAndDropMappingRepository dragAndDropMappingRepository, + ShortAnswerMappingRepository shortAnswerMappingRepository) { super(dragAndDropMappingRepository, shortAnswerMappingRepository); this.quizExerciseRepository = quizExerciseRepository; this.resultRepository = resultRepository; @@ -52,6 +70,7 @@ public QuizExerciseService(QuizExerciseRepository quizExerciseRepository, DragAn this.quizStatisticService = quizStatisticService; this.quizBatchService = quizBatchService; this.exerciseSpecificationService = exerciseSpecificationService; + this.fileService = fileService; } /** @@ -97,16 +116,19 @@ private void updateResultsOnQuizChanges(QuizExercise quizExercise) { /** * @param quizExercise the changed quiz exercise from the client * @param originalQuizExercise the original quiz exercise (with statistics) + * @param files the files that were uploaded * @return the updated quiz exercise with the changed statistics */ - public QuizExercise reEvaluate(QuizExercise quizExercise, QuizExercise originalQuizExercise) { - + public QuizExercise reEvaluate(QuizExercise quizExercise, QuizExercise originalQuizExercise, @Nonnull List files) throws IOException { quizExercise.undoUnallowedChanges(originalQuizExercise); + validateQuizExerciseFiles(quizExercise, files, false); + boolean updateOfResultsAndStatisticsNecessary = quizExercise.checkIfRecalculationIsNecessary(originalQuizExercise); // update QuizExercise quizExercise.setMaxPoints(quizExercise.getOverallQuizPoints()); quizExercise.reconnectJSONIgnoreAttributes(); + handleDndQuizFileUpdates(quizExercise, originalQuizExercise, files); // adjust existing results if an answer or a question was deleted and recalculate them updateResultsOnQuizChanges(quizExercise); @@ -191,6 +213,177 @@ public SearchResultPageDTO getAllOnPageWithSize(final PageableSear return new SearchResultPageDTO<>(exercisePage.getContent(), exercisePage.getTotalPages()); } + /** + * Verifies that for DragAndDropQuestions all files are present and valid. Saves the files and updates the exercise accordingly. + * + * @param quizExercise the quiz exercise to create + * @param files the provided files + */ + public void handleDndQuizFileCreation(QuizExercise quizExercise, List files) throws IOException { + List nullsafeFiles = files == null ? new ArrayList<>() : files; + validateQuizExerciseFiles(quizExercise, nullsafeFiles, true); + Map fileMap = nullsafeFiles.stream().collect(Collectors.toMap(MultipartFile::getOriginalFilename, file -> file)); + + for (var question : quizExercise.getQuizQuestions()) { + if (question instanceof DragAndDropQuestion dragAndDropQuestion) { + if (dragAndDropQuestion.getBackgroundFilePath() != null) { + saveDndQuestionBackground(dragAndDropQuestion, fileMap, null); + } + handleDndQuizDragItemsCreation(dragAndDropQuestion, fileMap); + } + } + } + + private void handleDndQuizDragItemsCreation(DragAndDropQuestion dragAndDropQuestion, Map fileMap) throws IOException { + for (var dragItem : dragAndDropQuestion.getDragItems()) { + if (dragItem.getPictureFilePath() != null) { + saveDndDragItemPicture(dragItem, fileMap, null); + } + } + } + + /** + * Verifies that for DragAndDropQuestions all files are present and valid. Saves the files and updates the exercise accordingly. + * Ignores unchanged paths and removes deleted background images. + * + * @param updatedExercise the updated quiz exercise + * @param originalExercise the original quiz exercise + * @param files the provided files + */ + public void handleDndQuizFileUpdates(QuizExercise updatedExercise, QuizExercise originalExercise, List files) throws IOException { + List nullsafeFiles = files == null ? new ArrayList<>() : files; + validateQuizExerciseFiles(updatedExercise, nullsafeFiles, false); + // Find old drag items paths + Set oldPaths = getAllPathsFromDragAndDropQuestionsOfExercise(originalExercise); + // Init files to remove with all old paths + Set filesToRemove = new HashSet<>(oldPaths); + + Map fileMap = nullsafeFiles.stream().collect(Collectors.toMap(MultipartFile::getOriginalFilename, file -> file)); + + for (var question : updatedExercise.getQuizQuestions()) { + if (question instanceof DragAndDropQuestion dragAndDropQuestion) { + handleDndQuestionUpdate(dragAndDropQuestion, oldPaths, filesToRemove, fileMap, dragAndDropQuestion); + } + } + + fileService.deleteFiles(filesToRemove.stream().map(Paths::get).toList()); + } + + private Set getAllPathsFromDragAndDropQuestionsOfExercise(QuizExercise quizExercise) { + Set paths = new HashSet<>(); + for (var question : quizExercise.getQuizQuestions()) { + if (question instanceof DragAndDropQuestion dragAndDropQuestion) { + if (dragAndDropQuestion.getBackgroundFilePath() != null) { + paths.add(dragAndDropQuestion.getBackgroundFilePath()); + } + paths.addAll(dragAndDropQuestion.getDragItems().stream().map(DragItem::getPictureFilePath).filter(Objects::nonNull).collect(Collectors.toSet())); + } + } + return paths; + } + + private void handleDndQuestionUpdate(DragAndDropQuestion dragAndDropQuestion, Set oldPaths, Set filesToRemove, Map fileMap, + DragAndDropQuestion questionUpdate) throws IOException { + String newBackgroundPath = dragAndDropQuestion.getBackgroundFilePath(); + + // Don't do anything if the path is null because it's getting removed + if (newBackgroundPath != null) { + if (oldPaths.contains(newBackgroundPath)) { + // Path didn't change + filesToRemove.remove(dragAndDropQuestion.getBackgroundFilePath()); + } + else { + // Path changed and file was provided + saveDndQuestionBackground(dragAndDropQuestion, fileMap, questionUpdate.getId()); + } + } + + for (var dragItem : dragAndDropQuestion.getDragItems()) { + String newDragItemPath = dragItem.getPictureFilePath(); + if (dragItem.getPictureFilePath() != null && !oldPaths.contains(newDragItemPath)) { + // Path changed and file was provided + saveDndDragItemPicture(dragItem, fileMap, questionUpdate.getId()); + } + } + } + + /** + * Verifies that the provided files match the provided filenames in the exercise entity. + * + * @param quizExercise the quiz exercise to validate + * @param providedFiles the provided files to validate + * @param isCreate On create all files get validated, on update only changed files get validated + */ + public void validateQuizExerciseFiles(QuizExercise quizExercise, @Nonnull List providedFiles, boolean isCreate) { + long fileCount = providedFiles.size(); + Set exerciseFileNames = getAllPathsFromDragAndDropQuestionsOfExercise(quizExercise); + Set newFileNames = isCreate ? exerciseFileNames : exerciseFileNames.stream().filter(fileName -> { + try { + return !Files.exists(Path.of(fileService.actualPathForPublicPathOrThrow(fileName))); + } + catch (FilePathParsingException e) { + // File is not in the internal API format and hence expected to be a new file + return true; + } + }).collect(Collectors.toSet()); + + if (newFileNames.size() != fileCount) { + throw new BadRequestAlertException("Number of files does not match number of new drag items and backgrounds", ENTITY_NAME, null); + } + Set providedFileNames = providedFiles.stream().map(MultipartFile::getOriginalFilename).collect(Collectors.toSet()); + if (!newFileNames.equals(providedFileNames)) { + throw new BadRequestAlertException("File names do not match new drag item and background file names", ENTITY_NAME, null); + } + } + + /** + * Saves the background image of a drag and drop question without saving the question itself + * + * @param question the drag and drop question + * @param files all provided files + * @param questionId the id of the question, null on creation + */ + public void saveDndQuestionBackground(DragAndDropQuestion question, Map files, @Nullable Long questionId) throws IOException { + MultipartFile file = files.get(question.getBackgroundFilePath()); + if (file == null) { + // Should not be reached as the file is validated before + throw new BadRequestAlertException("The file " + question.getBackgroundFilePath() + " was not provided", ENTITY_NAME, null); + } + + question.setBackgroundFilePath(saveDragAndDropImage(FilePathService.getDragAndDropBackgroundFilePath(), file, questionId)); + } + + /** + * Saves the picture of a drag item without saving the drag item itself + * + * @param dragItem the drag item + * @param files all provided files + * @param questionId the id of the question, null on creation + */ + public void saveDndDragItemPicture(DragItem dragItem, Map files, @Nullable Long questionId) throws IOException { + MultipartFile file = files.get(dragItem.getPictureFilePath()); + if (file == null) { + // Should not be reached as the file is validated before + throw new BadRequestAlertException("The file " + dragItem.getPictureFilePath() + " was not provided", ENTITY_NAME, null); + } + + dragItem.setPictureFilePath(saveDragAndDropImage(FilePathService.getDragItemFilePath(), file, questionId)); + } + + /** + * Saves an image for an DragAndDropQuestion. Either a background image or a drag item image. + * + * @return the public path of the saved image + */ + private String saveDragAndDropImage(String basePath, MultipartFile file, @Nullable Long questionId) throws IOException { + File fileLocation = fileService.generateTargetFile(file.getOriginalFilename(), basePath, false); + String filePath = fileLocation.toPath().toString(); + String savedFileName = fileService.saveFile(FilenameUtils.getPath(filePath), FilenameUtils.getName(filePath), null, FilenameUtils.getExtension(filePath), true, file); + String id = questionId == null ? Constants.FILEPATH_ID_PLACEHOLDER : questionId.toString(); + Path path = Path.of(basePath, id, savedFileName); + return fileService.publicPathForActualPathOrThrow(path.toString(), questionId); + } + /** * Reset the invalid status of questions of given quizExercise to false * diff --git a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamImportService.java b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamImportService.java index 12121000919e..429781dfd391 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/exam/ExamImportService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/exam/ExamImportService.java @@ -1,5 +1,6 @@ package de.tum.in.www1.artemis.service.exam; +import java.io.IOException; import java.util.*; import org.springframework.stereotype.Service; @@ -88,7 +89,7 @@ public ExamImportService(TextExerciseImportService textExerciseImportService, Te * @param targetCourseId the course to which the exam should be imported * @return the copied Exam with Exercise Groups and Exercises */ - public Exam importExamWithExercises(Exam examToCopy, long targetCourseId) { + public Exam importExamWithExercises(Exam examToCopy, long targetCourseId) throws IOException { Course targetCourse = courseRepository.findByIdElseThrow(targetCourseId); @@ -113,7 +114,7 @@ public Exam importExamWithExercises(Exam examToCopy, long targetCourseId) { * @param courseId the associated course of the exam * @return a List of all Exercise Groups of the target exam */ - public List importExerciseGroupsWithExercisesToExistingExam(List exerciseGroupsToCopy, long targetExamId, long courseId) { + public List importExerciseGroupsWithExercisesToExistingExam(List exerciseGroupsToCopy, long targetExamId, long courseId) throws IOException { Course targetCourse = courseRepository.findByIdElseThrow(courseId); preCheckProgrammingExercisesForTitleAndShortNameUniqueness(exerciseGroupsToCopy, targetCourse.getShortName()); @@ -220,7 +221,7 @@ private void preCheckProgrammingExercisesForTitleAndShortNameUniqueness(List exerciseGroupsToCopy, Exam targetExam) { + private void copyExerciseGroupsWithExercisesToExam(List exerciseGroupsToCopy, Exam targetExam) throws IOException { // Only exercise groups with at least one exercise should be imported. List filteredExerciseGroupsToCopy = exerciseGroupsToCopy.stream().filter(exerciseGroup -> !exerciseGroup.getExercises().isEmpty()).toList(); // If no exercise group is existent, we can aboard the process @@ -257,38 +258,35 @@ private void copyExerciseGroupsWithExercisesToExam(List exerciseG * @param exerciseGroupToCopy the exercise group to copy * @param exerciseGroupCopied the copied exercise group, i.e. the ones attached to the new exam */ - private void addExercisesToExerciseGroup(ExerciseGroup exerciseGroupToCopy, ExerciseGroup exerciseGroupCopied) { + private void addExercisesToExerciseGroup(ExerciseGroup exerciseGroupToCopy, ExerciseGroup exerciseGroupCopied) throws IOException { // Copy each exercise within the existing Exercise Group - exerciseGroupToCopy.getExercises().forEach(exerciseToCopy -> { - + for (Exercise exerciseToCopy : exerciseGroupToCopy.getExercises()) { // We need to set the new Exercise Group to the old exercise, so the new exercise group is correctly set for the new exercise exerciseToCopy.setExerciseGroup(exerciseGroupCopied); - Exercise exerciseCopied = null; - - switch (exerciseToCopy.getExerciseType()) { + Optional exerciseCopied = switch (exerciseToCopy.getExerciseType()) { case MODELING -> { final Optional optionalOriginalModellingExercise = modelingExerciseRepository .findByIdWithExampleSubmissionsAndResults(exerciseToCopy.getId()); // We do not want to abort the whole exam import process, we only skip the relevant exercise if (optionalOriginalModellingExercise.isEmpty()) { - break; + yield Optional.empty(); } - exerciseCopied = modelingExerciseImportService.importModelingExercise(optionalOriginalModellingExercise.get(), (ModelingExercise) exerciseToCopy); + yield Optional.of(modelingExerciseImportService.importModelingExercise(optionalOriginalModellingExercise.get(), (ModelingExercise) exerciseToCopy)); } case TEXT -> { final Optional optionalOriginalTextExercise = textExerciseRepository.findByIdWithExampleSubmissionsAndResults(exerciseToCopy.getId()); if (optionalOriginalTextExercise.isEmpty()) { - break; + yield Optional.empty(); } - exerciseCopied = textExerciseImportService.importTextExercise(optionalOriginalTextExercise.get(), (TextExercise) exerciseToCopy); + yield Optional.of(textExerciseImportService.importTextExercise(optionalOriginalTextExercise.get(), (TextExercise) exerciseToCopy)); } case PROGRAMMING -> { final Optional optionalOriginalProgrammingExercise = programmingExerciseRepository .findByIdWithEagerTestCasesStaticCodeAnalysisCategoriesHintsAndTemplateAndSolutionParticipationsAndAuxRepos(exerciseToCopy.getId()); if (optionalOriginalProgrammingExercise.isEmpty()) { - break; + yield Optional.empty(); } var originalProgrammingExercise = optionalOriginalProgrammingExercise.get(); // Fetching the tasks separately, as putting it in the query above leads to Hibernate duplicating the tasks. @@ -296,31 +294,29 @@ private void addExercisesToExerciseGroup(ExerciseGroup exerciseGroupToCopy, Exer originalProgrammingExercise.setTasks(new ArrayList<>(templateTasks)); prepareProgrammingExerciseForExamImport((ProgrammingExercise) exerciseToCopy); - exerciseCopied = programmingExerciseImportService.importProgrammingExercise(originalProgrammingExercise, (ProgrammingExercise) exerciseToCopy, false, false); + yield Optional.of(programmingExerciseImportService.importProgrammingExercise(originalProgrammingExercise, (ProgrammingExercise) exerciseToCopy, false, false)); } case FILE_UPLOAD -> { final Optional optionalFileUploadExercise = fileUploadExerciseRepository.findById(exerciseToCopy.getId()); if (optionalFileUploadExercise.isEmpty()) { - break; + yield Optional.empty(); } - exerciseCopied = fileUploadExerciseImportService.importFileUploadExercise(optionalFileUploadExercise.get(), (FileUploadExercise) exerciseToCopy); + yield Optional.of(fileUploadExerciseImportService.importFileUploadExercise(optionalFileUploadExercise.get(), (FileUploadExercise) exerciseToCopy)); } case QUIZ -> { final Optional optionalOriginalQuizExercise = quizExerciseRepository.findById(exerciseToCopy.getId()); if (optionalOriginalQuizExercise.isEmpty()) { - break; + yield Optional.empty(); } - exerciseCopied = quizExerciseImportService.importQuizExercise(optionalOriginalQuizExercise.get(), (QuizExercise) exerciseToCopy); + // We don't allow a modification of the exercise at this point, so we can just pass an empty list of files. + yield Optional.of(quizExerciseImportService.importQuizExercise(optionalOriginalQuizExercise.get(), (QuizExercise) exerciseToCopy, new ArrayList<>())); } - - } - // Attach the newly created Exercise to the new Exercise Group only if the importing was sucessful - if (exerciseCopied != null) { - exerciseGroupCopied.addExercise(exerciseCopied); - } - }); + }; + // Attach the newly created Exercise to the new Exercise Group only if the importing was successful + exerciseCopied.ifPresent(exerciseGroupCopied::addExercise); + } exerciseGroupRepository.save(exerciseGroupCopied); } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/AttachmentResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/AttachmentResource.java index 47e27d39c576..f8a22a09e17e 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/AttachmentResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/AttachmentResource.java @@ -8,7 +8,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; -import org.springframework.cache.CacheManager; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -56,17 +55,14 @@ public class AttachmentResource { private final FilePathService filePathService; - private final CacheManager cacheManager; - public AttachmentResource(AttachmentRepository attachmentRepository, GroupNotificationService groupNotificationService, AuthorizationCheckService authorizationCheckService, - UserRepository userRepository, FileService fileService, FilePathService filePathService, CacheManager cacheManager) { + UserRepository userRepository, FileService fileService, FilePathService filePathService) { this.attachmentRepository = attachmentRepository; this.groupNotificationService = groupNotificationService; this.authorizationCheckService = authorizationCheckService; this.userRepository = userRepository; this.fileService = fileService; this.filePathService = filePathService; - this.cacheManager = cacheManager; } /** @@ -87,7 +83,7 @@ public ResponseEntity createAttachment(@RequestPart Attachment attac attachment.setLink(pathString); Attachment result = attachmentRepository.save(attachment); - this.cacheManager.getCache("files").evict(filePathService.actualPathForPublicPath(URI.create(result.getLink())).toString()); + this.fileService.evictCacheForPath(filePathService.actualPathForPublicPath(URI.create(result.getLink()))); return ResponseEntity.created(new URI("/api/attachments/" + result.getId())).body(result); } @@ -118,7 +114,7 @@ public ResponseEntity updateAttachment(@PathVariable Long attachment } Attachment result = attachmentRepository.save(attachment); - this.cacheManager.getCache("files").evict(filePathService.actualPathForPublicPath(URI.create(result.getLink())).toString()); + this.fileService.evictCacheForPath(filePathService.actualPathForPublicPath(URI.create(result.getLink()))); if (notificationText != null) { groupNotificationService.notifyStudentGroupAboutAttachmentChange(result, notificationText); } @@ -173,7 +169,7 @@ public ResponseEntity deleteAttachment(@PathVariable Long attachmentId) { course = attachment.getLecture().getCourse(); relatedEntity = "lecture " + attachment.getLecture().getTitle(); try { - this.cacheManager.getCache("files").evict(filePathService.actualPathForPublicPath(URI.create(attachment.getLink())).toString()); + this.fileService.evictCacheForPath(filePathService.actualPathForPublicPath(URI.create(attachment.getLink()))); } catch (RuntimeException exception) { // this catch is required for deleting wrongly formatted attachment database entries diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java index 9cd4cb4b817f..e45ba3d59494 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java @@ -7,6 +7,7 @@ import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; +import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.nio.file.Path; @@ -316,7 +317,7 @@ public ResponseEntity updateExamWorkingTime(@PathVariable Long courseId, @ */ @PostMapping("courses/{courseId}/exam-import") @EnforceAtLeastInstructor - public ResponseEntity importExamWithExercises(@PathVariable Long courseId, @RequestBody Exam examToBeImported) throws URISyntaxException { + public ResponseEntity importExamWithExercises(@PathVariable Long courseId, @RequestBody Exam examToBeImported) throws URISyntaxException, IOException { log.debug("REST request to import an exam : {}", examToBeImported); // Step 1: Check if Exam has an ID diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ExerciseGroupResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ExerciseGroupResource.java index bd53adcc1cf5..e26c4ac72aba 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ExerciseGroupResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ExerciseGroupResource.java @@ -1,5 +1,6 @@ package de.tum.in.www1.artemis.web.rest; +import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.util.List; @@ -143,7 +144,8 @@ public ResponseEntity updateExerciseGroup(@PathVariable Long cour */ @PostMapping("/courses/{courseId}/exams/{examId}/import-exercise-group") @EnforceAtLeastEditor - public ResponseEntity> importExerciseGroup(@PathVariable Long courseId, @PathVariable Long examId, @RequestBody List updatedExerciseGroup) { + public ResponseEntity> importExerciseGroup(@PathVariable Long courseId, @PathVariable Long examId, @RequestBody List updatedExerciseGroup) + throws IOException { log.debug("REST request to import {} exercise group(s) to exam {}", updatedExerciseGroup.size(), examId); examAccessService.checkCourseAndExamAccessForEditorElseThrow(courseId, examId); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/QuizExerciseResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/QuizExerciseResource.java index 5e11afd43ae4..ca59b30eae17 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/QuizExerciseResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/QuizExerciseResource.java @@ -1,28 +1,38 @@ package de.tum.in.www1.artemis.web.rest; +import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; +import java.nio.file.Path; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import de.tum.in.www1.artemis.config.Constants; import de.tum.in.www1.artemis.domain.Course; import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.enumeration.QuizMode; import de.tum.in.www1.artemis.domain.metis.conversation.Channel; +import de.tum.in.www1.artemis.domain.quiz.DragAndDropQuestion; +import de.tum.in.www1.artemis.domain.quiz.DragItem; import de.tum.in.www1.artemis.domain.quiz.QuizBatch; import de.tum.in.www1.artemis.domain.quiz.QuizExercise; +import de.tum.in.www1.artemis.exception.FilePathParsingException; import de.tum.in.www1.artemis.exception.QuizJoinException; import de.tum.in.www1.artemis.repository.*; import de.tum.in.www1.artemis.repository.metis.conversation.ChannelRepository; @@ -93,49 +103,58 @@ public class QuizExerciseResource { private final SubmissionRepository submissionRepository; + private final FileService fileService; + private final ChannelService channelService; private final ChannelRepository channelRepository; - public QuizExerciseResource(QuizExerciseService quizExerciseService, QuizExerciseRepository quizExerciseRepository, CourseService courseService, UserRepository userRepository, - ExerciseDeletionService exerciseDeletionServiceService, QuizScheduleService quizScheduleService, QuizStatisticService quizStatisticService, - QuizExerciseImportService quizExerciseImportService, AuthorizationCheckService authCheckService, CourseRepository courseRepository, - GroupNotificationService groupNotificationService, ExerciseService exerciseService, ExamDateService examDateService, QuizMessagingService quizMessagingService, + private final FilePathService filePathService; + + public QuizExerciseResource(QuizExerciseService quizExerciseService, QuizMessagingService quizMessagingService, QuizExerciseRepository quizExerciseRepository, + UserRepository userRepository, CourseService courseService, CourseRepository courseRepository, ExerciseService exerciseService, + ExerciseDeletionService exerciseDeletionService, ExamDateService examDateService, QuizScheduleService quizScheduleService, QuizStatisticService quizStatisticService, + QuizExerciseImportService quizExerciseImportService, AuthorizationCheckService authCheckService, GroupNotificationService groupNotificationService, GroupNotificationScheduleService groupNotificationScheduleService, StudentParticipationRepository studentParticipationRepository, QuizBatchService quizBatchService, - QuizBatchRepository quizBatchRepository, SubmissionRepository submissionRepository, ChannelService channelService, ChannelRepository channelRepository) { + QuizBatchRepository quizBatchRepository, SubmissionRepository submissionRepository, FileService fileService, ChannelService channelService, + ChannelRepository channelRepository, FilePathService filePathService) { this.quizExerciseService = quizExerciseService; + this.quizMessagingService = quizMessagingService; this.quizExerciseRepository = quizExerciseRepository; - this.exerciseDeletionService = exerciseDeletionServiceService; this.userRepository = userRepository; this.courseService = courseService; + this.courseRepository = courseRepository; + this.exerciseService = exerciseService; + this.exerciseDeletionService = exerciseDeletionService; + this.examDateService = examDateService; this.quizScheduleService = quizScheduleService; this.quizStatisticService = quizStatisticService; this.quizExerciseImportService = quizExerciseImportService; this.authCheckService = authCheckService; this.groupNotificationService = groupNotificationService; - this.exerciseService = exerciseService; - this.examDateService = examDateService; - this.courseRepository = courseRepository; - this.quizMessagingService = quizMessagingService; this.groupNotificationScheduleService = groupNotificationScheduleService; this.studentParticipationRepository = studentParticipationRepository; this.quizBatchService = quizBatchService; this.quizBatchRepository = quizBatchRepository; this.submissionRepository = submissionRepository; + this.fileService = fileService; this.channelService = channelService; this.channelRepository = channelRepository; + this.filePathService = filePathService; } /** * POST /quiz-exercises : Create a new quizExercise. * * @param quizExercise the quizExercise to create + * @param files the files for drag and drop questions to upload (optional). The original file name must equal the file path of the image in {@code quizExercise} * @return the ResponseEntity with status 201 (Created) and with body the new quizExercise, or with status 400 (Bad Request) if the quizExercise has already an ID * @throws URISyntaxException if the Location URI syntax is incorrect */ - @PostMapping("/quiz-exercises") + @PostMapping(value = "/quiz-exercises", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @EnforceAtLeastEditor - public ResponseEntity createQuizExercise(@RequestBody QuizExercise quizExercise) throws URISyntaxException { + public ResponseEntity createQuizExercise(@RequestPart("exercise") QuizExercise quizExercise, + @RequestPart(value = "files", required = false) List files) throws URISyntaxException, IOException { log.info("REST request to create QuizExercise : {}", quizExercise); if (quizExercise.getId() != null) { throw new BadRequestAlertException("A new quizExercise cannot already have an ID", ENTITY_NAME, "idExists"); @@ -155,6 +174,8 @@ public ResponseEntity createQuizExercise(@RequestBody QuizExercise Course course = courseService.retrieveCourseOverExerciseGroupOrCourseId(quizExercise); authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, course, null); + quizExerciseService.handleDndQuizFileCreation(quizExercise, files); + QuizExercise result = quizExerciseService.save(quizExercise); channelService.createExerciseChannel(result, Optional.ofNullable(quizExercise.getChannelName())); @@ -164,22 +185,22 @@ public ResponseEntity createQuizExercise(@RequestBody QuizExercise } /** - * PUT /quiz-exercises : Updates an existing quizExercise. + * PUT /quiz-exercises/:exerciseId : Updates an existing quizExercise. * + * @param exerciseId the id of the quizExercise to save * @param quizExercise the quizExercise to update + * @param files the new files for drag and drop questions to upload (optional). The original file name must equal the file path of the image in {@code quizExercise} * @param notificationText about the quiz exercise update that should be displayed to the student group * @return the ResponseEntity with status 200 (OK) and with body the updated quizExercise, or with status 400 (Bad Request) if the quizExercise is not valid, or with status 500 * (Internal Server Error) if the quizExercise couldn't be updated - * @throws URISyntaxException if the Location URI syntax is incorrect */ - @PutMapping("/quiz-exercises") + @PutMapping(value = "/quiz-exercises/{exerciseId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @EnforceAtLeastEditor - public ResponseEntity updateQuizExercise(@RequestBody QuizExercise quizExercise, - @RequestParam(value = "notificationText", required = false) String notificationText) throws URISyntaxException { + public ResponseEntity updateQuizExercise(@PathVariable Long exerciseId, @RequestPart("exercise") QuizExercise quizExercise, + @RequestPart(value = "files", required = false) List files, @RequestParam(value = "notificationText", required = false) String notificationText) + throws IOException { log.info("REST request to update quiz exercise : {}", quizExercise); - if (quizExercise.getId() == null) { - return createQuizExercise(quizExercise); - } + quizExercise.setId(exerciseId); // check if quiz is valid if (!quizExercise.isValid()) { @@ -191,13 +212,15 @@ public ResponseEntity updateQuizExercise(@RequestBody QuizExercise // Valid exercises have set either a course or an exerciseGroup quizExercise.checkCourseAndExerciseGroupExclusivity(ENTITY_NAME); + + final var originalQuiz = quizExerciseRepository.findWithEagerQuestionsByIdOrElseThrow(exerciseId); + // Retrieve the course over the exerciseGroup or the given courseId - Course course = courseService.retrieveCourseOverExerciseGroupOrCourseId(quizExercise); + Course course = courseService.retrieveCourseOverExerciseGroupOrCourseId(originalQuiz); var user = userRepository.getUserWithGroupsAndAuthorities(); authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, course, user); // Forbid conversion between normal course exercise and exam exercise - final var originalQuiz = quizExerciseRepository.findByIdElseThrow(quizExercise.getId()); exerciseService.checkForConversionBetweenExamAndCourseExercise(quizExercise, originalQuiz, ENTITY_NAME); // check if quiz is has already started @@ -213,6 +236,8 @@ public ResponseEntity updateQuizExercise(@RequestBody QuizExercise quizExercise.setQuizBatches(batches); } + quizExerciseService.handleDndQuizFileUpdates(quizExercise, originalQuiz, files); + Channel updatedChannel = channelService.updateExerciseChannel(originalQuiz, quizExercise); quizExercise = quizExerciseService.save(quizExercise); @@ -542,14 +567,35 @@ public ResponseEntity performActionForQuizExercise(@PathVariable L @EnforceAtLeastInstructor public ResponseEntity deleteQuizExercise(@PathVariable Long quizExerciseId) { log.info("REST request to delete quiz exercise : {}", quizExerciseId); - var quizExercise = quizExerciseRepository.findByIdElseThrow(quizExerciseId); + var quizExercise = quizExerciseRepository.findWithEagerQuestionsByIdOrElseThrow(quizExerciseId); var user = userRepository.getUserWithGroupsAndAuthorities(); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.INSTRUCTOR, quizExercise, user); + List dragAndDropQuestions = quizExercise.getQuizQuestions().stream().filter(question -> question instanceof DragAndDropQuestion) + .map(question -> ((DragAndDropQuestion) question)).toList(); + List backgroundImagePaths = dragAndDropQuestions.stream().map(DragAndDropQuestion::getBackgroundFilePath).toList(); + List dragItemImagePaths = dragAndDropQuestions.stream().flatMap(question -> question.getDragItems().stream().map(DragItem::getPictureFilePath)).toList(); + List imagesToDelete = Stream.concat(backgroundImagePaths.stream(), dragItemImagePaths.stream()).map(path -> { + if (path == null) { + return null; + } + try { + return filePathService.actualPathForPublicPathOrThrow(URI.create(path)); + } + catch (FilePathParsingException e) { + // if the path is invalid, we can't delete it, but we don't want to fail the whole deletion + log.warn("Could not find file " + path + " for deletion"); + return null; + } + }).filter(Objects::nonNull).toList(); + // note: we use the exercise service here, because this one makes sure to clean up all lazy references correctly. exerciseService.logDeletion(quizExercise, quizExercise.getCourseViaExerciseGroupOrCourseMember(), user); exerciseDeletionService.delete(quizExerciseId, false, false); quizExerciseService.cancelScheduledQuiz(quizExerciseId); + + fileService.deleteFiles(imagesToDelete); + return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, quizExercise.getTitle())).build(); } @@ -562,12 +608,14 @@ public ResponseEntity deleteQuizExercise(@PathVariable Long quizExerciseId * * @param quizExerciseId the quiz id for the quiz that should be re-evaluated * @param quizExercise the quizExercise to re-evaluate + * @param files the files for drag and drop questions to upload (optional). The original file name must equal the file path of the image in {@code quizExercise} * @return the ResponseEntity with status 200 (OK) and with body the re-evaluated quizExercise, or with status 400 (Bad Request) if the quizExercise is not valid, or with * status 500 (Internal Server Error) if the quizExercise couldn't be re-evaluated */ - @PutMapping("/quiz-exercises/{quizExerciseId}/re-evaluate") + @PutMapping(value = "/quiz-exercises/{quizExerciseId}/re-evaluate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @EnforceAtLeastInstructor - public ResponseEntity reEvaluateQuizExercise(@PathVariable Long quizExerciseId, @RequestBody QuizExercise quizExercise) { + public ResponseEntity reEvaluateQuizExercise(@PathVariable Long quizExerciseId, @RequestPart("exercise") QuizExercise quizExercise, + @RequestPart(value = "files", required = false) List files) throws IOException { log.info("REST request to re-evaluate quiz exercise : {}", quizExerciseId); QuizExercise originalQuizExercise = quizExerciseRepository.findByIdWithQuestionsAndStatisticsElseThrow(quizExerciseId); @@ -586,7 +634,9 @@ else if (!originalQuizExercise.isQuizEnded()) { var user = userRepository.getUserWithGroupsAndAuthorities(); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.INSTRUCTOR, quizExercise, user); - quizExercise = quizExerciseService.reEvaluate(quizExercise, originalQuizExercise); + List nullsafeFiles = files == null ? new ArrayList<>() : files; + + quizExercise = quizExerciseService.reEvaluate(quizExercise, originalQuizExercise, nullsafeFiles); exerciseService.logUpdate(quizExercise, quizExercise.getCourseViaExerciseGroupOrCourseMember(), user); quizExercise.validateScoreSettings(); @@ -617,15 +667,16 @@ public ResponseEntity> getAllExercisesOnPage(P * entities will get cloned and assigned a new id. * * @param sourceExerciseId The ID of the original exercise which should get imported - * @param importedExercise The new exercise containing values that should get overwritten in the - * imported exercise, s.a. the title or difficulty + * @param importedExercise The new exercise containing values that should get overwritten in the imported exercise, s.a. the title or difficulty + * @param files the files for drag and drop questions to upload (optional). The original file name must equal the file path of the image in {@code quizExercise} * @return The imported exercise (200), a not found error (404) if the template does not exist, * or a forbidden error (403) if the user is not at least an instructor in the target course. * @throws URISyntaxException When the URI of the response entity is invalid */ - @PostMapping("/quiz-exercises/import/{sourceExerciseId}") + @PostMapping(value = "quiz-exercises/import/{sourceExerciseId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @EnforceAtLeastEditor - public ResponseEntity importExercise(@PathVariable long sourceExerciseId, @RequestBody QuizExercise importedExercise) throws URISyntaxException { + public ResponseEntity importExercise(@PathVariable long sourceExerciseId, @RequestPart("exercise") QuizExercise importedExercise, + @RequestPart(value = "files", required = false) List files) throws URISyntaxException, IOException { log.info("REST request to import from quiz exercise : {}", sourceExerciseId); if (sourceExerciseId <= 0 || (importedExercise.getCourseViaExerciseGroupOrCourseMember() == null && importedExercise.getExerciseGroup() == null)) { log.debug("Either the courseId or exerciseGroupId must be set for an import"); @@ -644,11 +695,14 @@ public ResponseEntity importExercise(@PathVariable long sourceExer return ResponseEntity.badRequest().headers(HeaderUtil.createFailureAlert(applicationName, true, ENTITY_NAME, "invalidQuiz", "The quiz exercise is invalid")).body(null); } + List nullsafeFiles = files != null ? files : new ArrayList<>(); + // validates general settings: points, dates importedExercise.validateGeneralSettings(); + quizExerciseService.validateQuizExerciseFiles(importedExercise, nullsafeFiles, false); final var originalQuizExercise = quizExerciseRepository.findByIdElseThrow(sourceExerciseId); - final var newQuizExercise = quizExerciseImportService.importQuizExercise(originalQuizExercise, importedExercise); + final var newQuizExercise = quizExerciseImportService.importQuizExercise(originalQuizExercise, importedExercise, nullsafeFiles); return ResponseEntity.created(new URI("/api/quiz-exercises/" + newQuizExercise.getId())) .headers(HeaderUtil.createEntityCreationAlert(applicationName, true, ENTITY_NAME, newQuizExercise.getId().toString())).body(newQuizExercise); } diff --git a/src/main/webapp/app/exercises/quiz/manage/apollon-diagrams/exercise-generation/quiz-exercise-generator.ts b/src/main/webapp/app/exercises/quiz/manage/apollon-diagrams/exercise-generation/quiz-exercise-generator.ts index 10128f71a4d1..689ac4111292 100644 --- a/src/main/webapp/app/exercises/quiz/manage/apollon-diagrams/exercise-generation/quiz-exercise-generator.ts +++ b/src/main/webapp/app/exercises/quiz/manage/apollon-diagrams/exercise-generation/quiz-exercise-generator.ts @@ -41,9 +41,9 @@ export async function generateDragAndDropQuizExercise( exclude: interactiveElements, }); const diagramBackground = await convertRenderedSVGToPNG(renderedDiagram); - const backgroundImageUploadResponse = await fileUploaderService.uploadFile(diagramBackground, 'diagram-background.png'); + const files = new Map(); + files.set('diagram-background.png', diagramBackground); - const backgroundFilePath = backgroundImageUploadResponse.path; const dragItems = new Map(); const dropLocations = new Map(); @@ -53,7 +53,7 @@ export async function generateDragAndDropQuizExercise( if (!element) { continue; } - const { dragItem, dropLocation } = await generateDragAndDropItem(element, model, renderedDiagram.clip, fileUploaderService); + const { dragItem, dropLocation } = await generateDragAndDropItem(element, model, renderedDiagram.clip, files); dragItems.set(element.id, dragItem!); dropLocations.set(element.id, dropLocation!); } @@ -62,13 +62,13 @@ export async function generateDragAndDropQuizExercise( const correctMappings = createCorrectMappings(dragItems, dropLocations, model); // Generate a drag-and-drop question object - const dragAndDropQuestion = createDragAndDropQuestion(title, backgroundFilePath, [...dragItems.values()], [...dropLocations.values()], correctMappings); + const dragAndDropQuestion = createDragAndDropQuestion(title, 'diagram-background.png', [...dragItems.values()], [...dropLocations.values()], correctMappings); // Generate a quiz exercise object const quizExercise = createDragAndDropQuizExercise(course, title, dragAndDropQuestion); // Save the quiz exercise - await lastValueFrom(quizExerciseService.create(quizExercise)); + await lastValueFrom(quizExerciseService.create(quizExercise, files)); return quizExercise; } @@ -129,7 +129,7 @@ function createDragAndDropQuestion( * @param {UMLModelElement} element A particular element of the UML model. * @param {UMLModel} model The complete UML model. * @param svgSize actual size of the generated svg - * @param {FileUploaderService} fileUploaderService To upload image base drag items. + * @param files a map of files that should be uploaded * * @return {Promise} A Promise resolving to a Drag and Drop mapping */ @@ -137,15 +137,15 @@ async function generateDragAndDropItem( element: UMLModelElement, model: UMLModel, svgSize: { width: number; height: number }, - fileUploaderService: FileUploaderService, + files: Map, ): Promise { const textualElementTypes: UMLElementType[] = [UMLElementType.ClassAttribute, UMLElementType.ClassMethod, UMLElementType.ObjectAttribute, UMLElementType.ObjectMethod]; if (element.type in UMLRelationshipType) { - return generateDragAndDropItemForRelationship(element, model, svgSize, fileUploaderService); + return generateDragAndDropItemForRelationship(element, model, svgSize, files); } else if (textualElementTypes.includes(element.type as UMLElementType)) { return generateDragAndDropItemForText(element, model, svgSize); } else { - return generateDragAndDropItemForElement(element, model, svgSize, fileUploaderService); + return generateDragAndDropItemForElement(element, model, svgSize, files); } } @@ -155,7 +155,7 @@ async function generateDragAndDropItem( * @param {UMLModelElement} element An element of the UML model. * @param {UMLModel} model The complete UML model. * @param svgSize actual size of the generated svg - * @param {FileUploaderService} fileUploaderService To upload image base drag items. + * @param files a map of files that should be uploaded * * @return {Promise} A Promise resolving to a Drag and Drop mapping */ @@ -163,14 +163,15 @@ async function generateDragAndDropItemForElement( element: UMLModelElement, model: UMLModel, svgSize: { width: number; height: number }, - fileUploaderService: FileUploaderService, + files: Map, ): Promise { const renderedElement: SVG = await ApollonEditor.exportModelAsSvg(model, { include: [element.id] }); const image = await convertRenderedSVGToPNG(renderedElement); - const imageUploadResponse = await fileUploaderService.uploadFile(image, `element-${element.id}.png`); + const imageName = `element-${element.id}.png`; + files.set(imageName, image); const dragItem = new DragItem(); - dragItem.pictureFilePath = imageUploadResponse.path; + dragItem.pictureFilePath = imageName; const dropLocation = computeDropLocation(renderedElement.clip, svgSize); return new DragAndDropMapping(dragItem, dropLocation); @@ -199,7 +200,7 @@ async function generateDragAndDropItemForText(element: UMLModelElement, model: U * @param {UMLModelElement} element A relationship of the UML model. * @param {UMLModel} model The complete UML model. * @param svgSize actual size of the generated svg - * @param {FileUploaderService} fileUploaderService To upload image base drag items. + * @param files a map of files that should be uploaded * * @return {Promise} A Promise resolving to a Drag and Drop mapping */ @@ -207,7 +208,7 @@ async function generateDragAndDropItemForRelationship( element: UMLModelElement, model: UMLModel, svgSize: { width: number; height: number }, - fileUploaderService: FileUploaderService, + files: Map, ): Promise { const MIN_SIZE = 30; @@ -223,10 +224,11 @@ async function generateDragAndDropItemForRelationship( const renderedElement: SVG = await ApollonEditor.exportModelAsSvg(model, { margin, include: [element.id] }); const image = await convertRenderedSVGToPNG(renderedElement); - const imageUploadResponse = await fileUploaderService.uploadFile(image, `relationship-${element.id}.png`); + const imageName = `relationship-${element.id}.png`; + files.set(imageName, image); const dragItem = new DragItem(); - dragItem.pictureFilePath = imageUploadResponse.path; + dragItem.pictureFilePath = imageName; const dropLocation = computeDropLocation(renderedElement.clip, svgSize); return new DragAndDropMapping(dragItem, dropLocation); diff --git a/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.html b/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.html index df3bed13b3c8..2bacaefad445 100644 --- a/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.html +++ b/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.html @@ -233,18 +233,15 @@

-
- - -
-
- +
-
+
-
- - -
-
- -
+
+ + + +
diff --git a/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts b/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts index 7f9c6b238a50..41b27b08613f 100644 --- a/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component.ts @@ -53,6 +53,7 @@ import { import { faFileImage } from '@fortawesome/free-regular-svg-icons'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { MAX_QUIZ_QUESTION_POINTS } from 'app/shared/constants/input.constants'; +import { FileService } from 'app/shared/http/file.service'; @Component({ selector: 'jhi-drag-and-drop-question-edit', @@ -69,33 +70,26 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte @Input() question: DragAndDropQuestion; @Input() questionIndex: number; @Input() reEvaluationInProgress: boolean; + @Input() filePool = new Map(); - @Output() questionUpdated = new EventEmitter(); - @Output() questionDeleted = new EventEmitter(); + @Output() questionUpdated = new EventEmitter(); + @Output() questionDeleted = new EventEmitter(); /** Question move up and down are used for re-evaluate **/ - @Output() questionMoveUp = new EventEmitter(); - @Output() questionMoveDown = new EventEmitter(); + @Output() questionMoveUp = new EventEmitter(); + @Output() questionMoveDown = new EventEmitter(); + @Output() addNewFile = new EventEmitter<{ fileName: string; path?: string; file: File }>(); + @Output() removeFile = new EventEmitter(); /** Ace Editor configuration constants **/ questionEditorText = ''; backupQuestion: DragAndDropQuestion; - - dragItemPicture?: string; - backgroundFile?: File; - backgroundFileName: string; - backgroundFilePath: string; - dragItemFile?: File; - dragItemFileName: string; - + filePreviewPaths: Map = new Map(); dropAllowed = false; - - showPreview: boolean; - isUploadingBackgroundFile: boolean; - isUploadingDragItemFile: boolean; + showPreview = false; /** Status boolean for collapse status **/ - isQuestionCollapsed: boolean; + isQuestionCollapsed = false; /** * Keep track of what the current drag action is doing @@ -147,6 +141,7 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte private modalService: NgbModal, private fileUploaderService: FileUploaderService, private changeDetector: ChangeDetectorRef, + private fileService: FileService, ) {} /** @@ -156,15 +151,6 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte // create deep copy as backup this.backupQuestion = cloneDeep(this.question); - /** Assign status booleans and strings **/ - this.showPreview = false; - this.isUploadingBackgroundFile = false; - this.backgroundFileName = ''; - this.backgroundFilePath = ''; - this.isUploadingDragItemFile = false; - this.dragItemFileName = ''; - this.isQuestionCollapsed = false; - /** Initialize DropLocation and MouseEvent objects **/ this.currentDropLocation = new DropLocation(); this.mouse = new DragAndDropMouseEvent(); @@ -184,17 +170,38 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte if (changes.question && changes.question.currentValue) { this.backupQuestion = cloneDeep(this.question); } + + if (!this.filePool || this.filePool.size == 0) { + return; + } + + this.filePool.forEach((value, fileName) => { + if (value.path && !this.filePreviewPaths.has(fileName)) { + console.log('add file to preview with path: ', fileName, value.path); + this.filePreviewPaths.set(fileName, value.path); + } + }); } ngAfterViewInit(): void { - if (this.question.backgroundFilePath) { - this.backgroundFilePath = this.question.backgroundFilePath; + if (this.question.backgroundFilePath && !this.filePreviewPaths.has(this.question.backgroundFilePath)) { + this.filePreviewPaths.set(this.question.backgroundFilePath, this.question.backgroundFilePath); // Trigger image render with the question background file path in order to adjust the click layer. setTimeout(() => { + this.changeDetector.markForCheck(); this.changeDetector.detectChanges(); }, 0); } + if (this.question.dragItems) { + for (const dragItem in this.question.dragItems) { + const path = this.question.dragItems[dragItem].pictureFilePath; + if (path && !this.filePreviewPaths.has(path)) { + this.filePreviewPaths.set(path, path); + } + } + } + this.backgroundImage.endLoadingProcess .pipe( filter((loadingStatus) => loadingStatus === ImageLoadingStatus.SUCCESS), @@ -246,43 +253,20 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte * event {object} Event object which contains the uploaded file */ setBackgroundFile(event: any): void { - if (event.target.files.length) { - const fileList: FileList = event.target.files as FileList; - this.backgroundFile = fileList[0]; - this.backgroundFileName = this.backgroundFile.name; + const fileList: FileList = event.target.files as FileList; + if (fileList.length) { + if (this.question.backgroundFilePath) { + this.removeFile.emit(this.question.backgroundFilePath); + } + const file = fileList[0]; + const fileName = this.fileService.getUniqueFileName(this.fileService.getExtension(file.name), this.filePool); + this.question.backgroundFilePath = fileName; + this.filePreviewPaths.set(fileName, URL.createObjectURL(file)); + this.addNewFile.emit({ fileName, file }); + this.changeDetector.detectChanges(); } } - /** - * Upload the selected file (from "Upload Background") and use it for the question's backgroundFilePath - */ - uploadBackground(): void { - const file = this.backgroundFile!; - - this.isUploadingBackgroundFile = true; - this.fileUploaderService.uploadFile(file, file.name).then( - (result) => { - this.question.backgroundFilePath = result.path; - this.isUploadingBackgroundFile = false; - this.backgroundFile = undefined; - this.backgroundFileName = ''; - this.backgroundFilePath = result.path!; - - // Trigger image reload. - this.changeDetector.detectChanges(); - - // Update save button state (enable it when the background image changes) - this.questionUpdated.emit(); - }, - (error) => { - console.error('Error during file upload in uploadBackground()', error.message); - this.isUploadingBackgroundFile = false; - this.backgroundFile = undefined; - this.backgroundFileName = ''; - }, - ); - } - /** * React to mousemove events on the entire page to update: * - mouse object (always) @@ -509,68 +493,27 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte this.questionUpdated.emit(); } - /** - * Sets drag item file. - * @param event {object} Event object which contains the uploaded file - */ - setDragItemFile(event: any): void { - if (event.target.files.length) { - const fileList: FileList = event.target.files as FileList; - this.dragItemFile = fileList[0]; - this.dragItemFileName = this.dragItemFile.name; - } - } - /** * Add a Picture Drag Item with the selected file as its picture to the question */ - uploadDragItem(): void { - const file = this.dragItemFile!; - - this.isUploadingDragItemFile = true; - this.fileUploaderService.uploadFile(file, file.name).then( - (result) => { - // Add drag item to question - if (!this.question.dragItems) { - this.question.dragItems = []; - } - const dragItem = new DragItem(); - dragItem.pictureFilePath = result.path; - this.question.dragItems.push(dragItem); - this.questionUpdated.emit(); - this.isUploadingDragItemFile = false; - this.dragItemFile = undefined; - this.dragItemFileName = ''; - }, - (error) => { - console.error('Error during file upload in uploadDragItem()', error.message); - this.isUploadingDragItemFile = false; - this.dragItemFile = undefined; - this.dragItemFileName = ''; - }, - ); - } + createImageDragItem(event: any): void { + const dragItemFile = this.getFileFromEvent(event); + if (!dragItemFile) { + return; + } + const fileName = this.fileService.getUniqueFileName(this.fileService.getExtension(dragItemFile.name), this.filePool); + this.addNewFile.emit({ fileName, file: dragItemFile }); + this.filePreviewPaths.set(fileName, URL.createObjectURL(dragItemFile)); - /** - * Upload a Picture for Drag Item Change with the selected file as its picture - */ - uploadPictureForDragItemChange(): void { - const file = this.dragItemFile!; + const dragItem = new DragItem(); + dragItem.pictureFilePath = fileName; + // Add drag item to question + if (!this.question.dragItems) { + this.question.dragItems = []; + } + this.question.dragItems.push(dragItem); - this.isUploadingDragItemFile = true; - this.fileUploaderService.uploadFile(file, file.name).then( - (result) => { - this.dragItemPicture = result.path; - this.questionUpdated.emit(); - this.isUploadingDragItemFile = false; - this.dragItemFile = undefined; - }, - (error) => { - console.error('Error during file upload in uploadPictureForDragItemChange()', error.message); - this.isUploadingDragItemFile = false; - this.dragItemFile = undefined; - }, - ); + this.questionUpdated.emit(); } /** @@ -579,6 +522,10 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte */ deleteDragItem(dragItemToDelete: DragItem): void { this.question.dragItems = this.question.dragItems!.filter((dragItem) => dragItem !== dragItemToDelete); + if (dragItemToDelete.pictureFilePath) { + this.removeFile.emit(dragItemToDelete.pictureFilePath); + this.filePreviewPaths.delete(dragItemToDelete.pictureFilePath); + } this.deleteMappingsForDragItem(dragItemToDelete); } @@ -740,34 +687,39 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte * @param dragItem {dragItem} the dragItem, which will be changed */ changeToTextDragItem(dragItem: DragItem): void { + this.removeFile.emit(dragItem.pictureFilePath!); + this.filePreviewPaths.delete(dragItem.pictureFilePath!); dragItem.pictureFilePath = undefined; dragItem.text = 'Text'; + this.questionUpdated.emit(); } /** * Change Text-Drag-Item to Picture-Drag-Item with PictureFile: this.dragItemFile * @param dragItem {dragItem} the dragItem, which will be changed + * @param event file upload event */ - changeToPictureDragItem(dragItem: DragItem): void { - const file = this.dragItemFile!; - - this.isUploadingDragItemFile = true; - this.fileUploaderService.uploadFile(file, file.name).then( - (response) => { - this.dragItemPicture = response.path; - this.questionUpdated.emit(); - this.isUploadingDragItemFile = false; - if (this.dragItemPicture) { - dragItem.text = undefined; - dragItem.pictureFilePath = this.dragItemPicture; - } - }, - (error) => { - console.error('Error during file upload in changeToPictureDragItem()', error.message); - this.isUploadingDragItemFile = false; - this.dragItemFile = undefined; - }, - ); + changeToPictureDragItem(dragItem: DragItem, event: any): void { + const dragItemFile = this.getFileFromEvent(event); + if (!dragItemFile) { + return; + } + + const fileName = this.fileService.getUniqueFileName(this.fileService.getExtension(dragItemFile.name), this.filePool); + + this.addNewFile.emit({ fileName, file: dragItemFile }); + this.filePreviewPaths.set(fileName, URL.createObjectURL(dragItemFile)); + dragItem.text = undefined; + dragItem.pictureFilePath = fileName; + this.questionUpdated.emit(); + } + + private getFileFromEvent(event: any): File | undefined { + const fileList = event.target.files as FileList; + if (!fileList.length) { + return undefined; + } + return fileList[0]; } /** @@ -806,9 +758,8 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte * Resets background-picture */ resetBackground(): void { + this.removeFile.emit(this.question.backgroundFilePath!); this.question.backgroundFilePath = this.backupQuestion.backgroundFilePath; - this.backgroundFile = undefined; - this.isUploadingBackgroundFile = false; } /** @@ -837,6 +788,10 @@ export class DragAndDropQuestionEditComponent implements OnInit, OnChanges, Afte // Remove current DragItem at given index and insert the backup at the same position this.question.dragItems!.splice(dragItemIndex, 1); this.question.dragItems!.splice(dragItemIndex, 0, backupDragItem); + if (dragItem.pictureFilePath) { + this.removeFile.emit(dragItem.pictureFilePath); + this.filePreviewPaths.delete(dragItem.pictureFilePath); + } } /** diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-detail.component.html b/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-detail.component.html index 4b3b80f67052..c4b78ac11737 100644 --- a/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-detail.component.html +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-detail.component.html @@ -209,7 +209,7 @@

(); + filesMap.forEach((value, key) => { + files.set(key, value.file); + }); this.isSaving = true; - this.quizQuestionsEditComponent.parseAllQuestions(); + this.quizQuestionListEditComponent.parseAllQuestions(); if (this.quizExercise.id !== undefined) { if (this.isImport) { - this.quizExerciseService.import(this.quizExercise).subscribe({ + this.quizExerciseService.import(this.quizExercise, files).subscribe({ next: (quizExerciseResponse: HttpResponse) => { if (quizExerciseResponse.body) { this.onSaveSuccess(quizExerciseResponse.body); @@ -419,7 +424,7 @@ export class QuizExerciseDetailComponent extends QuizExerciseValidationDirective if (this.notificationText) { requestOptions.notificationText = this.notificationText; } - this.quizExerciseService.update(this.quizExercise, requestOptions).subscribe({ + this.quizExerciseService.update(this.quizExercise.id, this.quizExercise, files, requestOptions).subscribe({ next: (quizExerciseResponse: HttpResponse) => { this.notificationText = undefined; if (quizExerciseResponse.body) { @@ -432,7 +437,7 @@ export class QuizExerciseDetailComponent extends QuizExerciseValidationDirective }); } } else { - this.quizExerciseService.create(this.quizExercise).subscribe({ + this.quizExerciseService.create(this.quizExercise, files).subscribe({ next: (quizExerciseResponse: HttpResponse) => { if (quizExerciseResponse.body) { this.onSaveSuccess(quizExerciseResponse.body); diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-popup.service.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-popup.service.ts index 1102a86ec9ce..dcbfdc19ae4f 100644 --- a/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-popup.service.ts +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-popup.service.ts @@ -21,10 +21,10 @@ export class QuizExercisePopupService { * @param component the content that should be shown * @param quizExercise the quiz exercise for which the modal should be shown */ - open(component: Component, quizExercise: QuizExercise): Promise { + open(component: Component, quizExercise: QuizExercise, files: Map): Promise { return new Promise((resolve) => { if (this.ngbModalRef == undefined) { - this.ngbModalRef = this.quizExerciseModalRef(component, quizExercise); + this.ngbModalRef = this.quizExerciseModalRef(component, quizExercise, files); } resolve(this.ngbModalRef); }); @@ -35,9 +35,10 @@ export class QuizExercisePopupService { * @param component the content that should be shown * @param quizExercise the quiz exercise for which the modal should be shown */ - quizExerciseModalRef(component: Component, quizExercise: QuizExercise): NgbModalRef { + quizExerciseModalRef(component: Component, quizExercise: QuizExercise, files: Map): NgbModalRef { const modalRef: NgbModalRef = this.modalService.open(component, { size: 'lg', backdrop: 'static' }); modalRef.componentInstance.quizExercise = quizExercise; + modalRef.componentInstance.files = files; modalRef.result.then( (result) => { if (result === 're-evaluate') { diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-exercise.service.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-exercise.service.ts index 997c1abb4eed..b560a88c3df5 100644 --- a/src/main/webapp/app/exercises/quiz/manage/quiz-exercise.service.ts +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-exercise.service.ts @@ -7,6 +7,7 @@ import { createRequestOption } from 'app/shared/util/request.util'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import { QuizQuestion } from 'app/entities/quiz/quiz-question.model'; import { downloadFile } from 'app/shared/util/download.util'; +import { objectToJsonBlob } from 'app/utils/blob-util'; export type EntityResponseType = HttpResponse; export type EntityArrayResponseType = HttpResponse; @@ -23,12 +24,20 @@ export class QuizExerciseService { /** * Create the given quiz exercise * @param quizExercise the quiz exercise that should be created + * @param files the files that should be uploaded */ - create(quizExercise: QuizExercise): Observable { + create(quizExercise: QuizExercise, files: Map): Observable { const copy = ExerciseService.convertExerciseDatesFromClient(quizExercise); copy.categories = ExerciseService.stringifyExerciseCategories(copy); + + const formData = new FormData(); + formData.append('exercise', objectToJsonBlob(copy)); + files.forEach((file, fileName) => { + formData.append('files', file, fileName); + }); + return this.http - .post(this.resourceUrl, copy, { observe: 'response' }) + .post(this.resourceUrl, formData, { observe: 'response' }) .pipe(map((res: EntityResponseType) => this.exerciseService.processExerciseEntityResponse(res))); } @@ -38,27 +47,44 @@ export class QuizExerciseService { * @param adaptedSourceQuizExercise The exercise that should be imported, including adapted values for the * new exercise. E.g. with another title than the original exercise. Old values that should get discarded * (like the old ID) will be handled by the server. + * @param files The files that should be uploaded */ - import(adaptedSourceQuizExercise: QuizExercise) { + import(adaptedSourceQuizExercise: QuizExercise, files: Map) { let copy = ExerciseService.convertExerciseDatesFromClient(adaptedSourceQuizExercise); copy = ExerciseService.setBonusPointsConstrainedByIncludedInOverallScore(copy); copy.categories = ExerciseService.stringifyExerciseCategories(copy); + + const formData = new FormData(); + formData.append('exercise', objectToJsonBlob(copy)); + files.forEach((file, fileName) => { + formData.append('files', file, fileName); + }); + return this.http - .post(`${this.resourceUrl}/import/${adaptedSourceQuizExercise.id}`, copy, { observe: 'response' }) + .post(`${this.resourceUrl}/import/${adaptedSourceQuizExercise.id}`, formData, { observe: 'response' }) .pipe(map((res: EntityResponseType) => this.exerciseService.processExerciseEntityResponse(res))); } /** * Update the given quiz exercise + * @param id the id of the quiz exercise that should be updated * @param quizExercise the quiz exercise that should be updated + * @param files the files that should be uploaded * @param req Additional parameters that should be passed to the server when updating the exercise */ - update(quizExercise: QuizExercise, req?: any): Observable { + update(id: number, quizExercise: QuizExercise, files: Map, req?: any): Observable { const options = createRequestOption(req); const copy = ExerciseService.convertExerciseDatesFromClient(quizExercise); copy.categories = ExerciseService.stringifyExerciseCategories(copy); + + const formData = new FormData(); + formData.append('exercise', objectToJsonBlob(copy)); + files.forEach((file, fileName) => { + formData.append('files', file, fileName); + }); + return this.http - .put(this.resourceUrl, copy, { params: options, observe: 'response' }) + .put(this.resourceUrl + '/' + id, formData, { params: options, observe: 'response' }) .pipe(map((res: EntityResponseType) => this.exerciseService.processExerciseEntityResponse(res))); } diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.ts index e187c92a31f8..fad106d8ae7a 100644 --- a/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit-existing.component.ts @@ -16,6 +16,7 @@ import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; import { onError } from 'app/shared/util/global.utils'; import { checkForInvalidFlaggedQuestions } from 'app/exercises/quiz/shared/quiz-manage-util.service'; +import { FileService } from 'app/shared/http/file.service'; export enum State { COURSE = 'Course', @@ -33,8 +34,10 @@ export enum State { export class QuizQuestionListEditExistingComponent implements OnChanges { @Input() show: boolean; @Input() courseId: number; + @Input() filePool: Map; @Output() onQuestionsAdded = new EventEmitter>(); + @Output() onFilesAdded = new EventEmitter>(); readonly MULTIPLE_CHOICE = QuizQuestionType.MULTIPLE_CHOICE; readonly DRAG_AND_DROP = QuizQuestionType.DRAG_AND_DROP; @@ -58,6 +61,7 @@ export class QuizQuestionListEditExistingComponent implements OnChanges { constructor( private modalService: NgbModal, + private fileService: FileService, private fileUploaderService: FileUploaderService, private courseManagementService: CourseManagementService, private examManagementService: ExamManagementService, @@ -218,13 +222,12 @@ export class QuizQuestionListEditExistingComponent implements OnChanges { * Ids are removed from new questions so that new id is assigned upon saving the quiz exercise. * Caution: All "invalid" flags are also removed. * Images are duplicated for drag and drop questions. - * @param questions list of questions + * @param quizQuestions questions to be added to currently edited quiz exercise */ async addQuestions(quizQuestions: Array) { const invalidQuizQuestionMap = checkForInvalidFlaggedQuestions(quizQuestions); const validQuizQuestions = quizQuestions.filter((quizQuestion) => !invalidQuizQuestionMap[quizQuestion.id!]); const invalidQuizQuestions = quizQuestions.filter((quizQuestion) => invalidQuizQuestionMap[quizQuestion.id!]); - let newQuizQuestions = validQuizQuestions; if (invalidQuizQuestions.length > 0) { const modal = this.modalService.open(QuizConfirmImportInvalidQuestionsModalComponent, { keyboard: true, @@ -237,13 +240,11 @@ export class QuizQuestionListEditExistingComponent implements OnChanges { }; }); modal.componentInstance.shouldImport.subscribe(async () => { - newQuizQuestions = newQuizQuestions.concat(invalidQuizQuestions); - newQuizQuestions = await this.convertExistingQuestionToNewQuestion(newQuizQuestions); - this.onQuestionsAdded.emit(newQuizQuestions); + const newQuizQuestions = validQuizQuestions.concat(invalidQuizQuestions); + return this.handleConversionOfExistingQuestions(newQuizQuestions); }); } else { - newQuizQuestions = await this.convertExistingQuestionToNewQuestion(newQuizQuestions); - this.onQuestionsAdded.emit(newQuizQuestions); + return this.handleConversionOfExistingQuestions(validQuizQuestions); } } @@ -274,8 +275,9 @@ export class QuizQuestionListEditExistingComponent implements OnChanges { * @param existingQuizQuestions the list of existing QuizQuestions to be converted * @return the list of new QuizQuestions */ - private async convertExistingQuestionToNewQuestion(existingQuizQuestions: Array): Promise> { + private async handleConversionOfExistingQuestions(existingQuizQuestions: Array) { const newQuizQuestions = new Array(); + const files: Map = new Map(); // To make sure all questions are duplicated (new resources are created), we need to remove some fields from the input questions, // This contains removing all ids, duplicating images in case of dnd questions, the question statistic and the exercise for (const question of existingQuizQuestions) { @@ -293,8 +295,9 @@ export class QuizQuestionListEditExistingComponent implements OnChanges { } else if (question.type === QuizQuestionType.DRAG_AND_DROP) { const dndQuestion = question as DragAndDropQuestion; // Get image from the old question and duplicate it on the server and then save new image to the question, - let fileUploadResponse = await this.fileUploaderService.duplicateFile(dndQuestion.backgroundFilePath!); - dndQuestion.backgroundFilePath = fileUploadResponse.path; + const backgroundFile = await this.fileService.getFile(dndQuestion.backgroundFilePath!, this.filePool); + files.set(backgroundFile.name, { path: dndQuestion.backgroundFilePath!, file: backgroundFile }); + dndQuestion.backgroundFilePath = backgroundFile.name; // For DropLocations, DragItems and CorrectMappings we need to provide tempID, // This tempID is used for keep tracking of mappings by server. The server removes tempID and generated a new id, @@ -306,8 +309,9 @@ export class QuizQuestionListEditExistingComponent implements OnChanges { for (const dragItem of dndQuestion.dragItems || []) { // Duplicating image on server. This is only valid for image drag items. For text drag items, pictureFilePath is undefined, if (dragItem.pictureFilePath) { - fileUploadResponse = await this.fileUploaderService.duplicateFile(dragItem.pictureFilePath); - dragItem.pictureFilePath = fileUploadResponse.path; + const dragItemFile = await this.fileService.getFile(dragItem.pictureFilePath, this.filePool); + files.set(dragItemFile.name, { path: dragItem.pictureFilePath, file: dragItemFile }); + dragItem.pictureFilePath = dragItemFile.name; } dragItem.tempID = dragItem.id; dragItem.id = undefined; @@ -323,8 +327,7 @@ export class QuizQuestionListEditExistingComponent implements OnChanges { // Duplicating image on server. This is only valid for image drag items. For text drag items, pictureFilePath is undefined, const correctMappingDragItem = correctMapping.dragItem!; if (correctMappingDragItem.pictureFilePath) { - fileUploadResponse = await this.fileUploaderService.duplicateFile(correctMappingDragItem.pictureFilePath); - correctMappingDragItem.pictureFilePath = fileUploadResponse.path; + correctMappingDragItem.pictureFilePath = dndQuestion.dragItems?.filter((dragItem) => dragItem.tempID === correctMappingDragItem.id)?.[0].pictureFilePath; } correctMappingDragItem.tempID = correctMappingDragItem?.id; correctMapping.dragItem!.id = undefined; @@ -361,6 +364,9 @@ export class QuizQuestionListEditExistingComponent implements OnChanges { } newQuizQuestions.push(question); } - return newQuizQuestions; + if (files.size > 0) { + this.onFilesAdded.emit(files); + } + this.onQuestionsAdded.emit(newQuizQuestions); } } diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit.component.html b/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit.component.html index 5ab19a20dd91..68c946a2a167 100644 --- a/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit.component.html +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit.component.html @@ -21,8 +21,11 @@

#editDragAndDrop [question]="quizQuestion" [questionIndex]="i + 1" + [filePool]="fileMap" (questionUpdated)="handleQuestionUpdated()" (questionDeleted)="handleQuestionDeleted(i)" + (addNewFile)="handleFileAdded($event)" + (removeFile)="handleFileRemoved($event)" > @@ -68,7 +71,9 @@

diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit.component.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit.component.ts index 56fce6280ea6..ed626808443e 100644 --- a/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-question-list-edit.component.ts @@ -27,13 +27,13 @@ export class QuizQuestionListEditComponent { @Output() onQuestionDeleted = new EventEmitter(); @ViewChildren('editMultipleChoice') - editMultipleChoiceQuestionComponents: QueryList; + editMultipleChoiceQuestionComponents: QueryList = new QueryList(); @ViewChildren('editDragAndDrop') - editDragAndDropQuestionComponents: QueryList; + editDragAndDropQuestionComponents: QueryList = new QueryList(); @ViewChildren('editShortAnswer') - editShortAnswerQuestionComponents: QueryList; + editShortAnswerQuestionComponents: QueryList = new QueryList(); readonly DRAG_AND_DROP = QuizQuestionType.DRAG_AND_DROP; readonly MULTIPLE_CHOICE = QuizQuestionType.MULTIPLE_CHOICE; @@ -43,6 +43,8 @@ export class QuizQuestionListEditComponent { showExistingQuestions = false; + fileMap = new Map(); + /** * Emit onQuestionUpdated if there is an update of the question. */ @@ -74,6 +76,32 @@ export class QuizQuestionListEditComponent { } } + /** + * Add the given file to the fileMap for later upload. + * @param event the event containing the file and its name. The name provided may be different from the actual file name but has to correspond to the name set in the entity object. + */ + handleFileAdded(event: { fileName: string; path?: string; file: File }) { + this.fileMap.set(event.fileName, { file: event.file, path: event.path }); + } + + /** + * Remove the given file from the fileMap. + * @param fileName the name of the file to be removed + */ + handleFileRemoved(fileName: string) { + this.fileMap.delete(fileName); + } + + /** + * Add all files from the given map to the fileMap. + * @param filesMap the map of files to be added + */ + handleFilesAdded(filesMap: Map) { + filesMap.forEach((value, fileName) => { + this.fileMap.set(fileName, value); + }); + } + /** * Add an empty multiple choice question to the quiz */ diff --git a/src/main/webapp/app/exercises/quiz/manage/re-evaluate/drag-and-drop-question/re-evaluate-drag-and-drop-question.component.ts b/src/main/webapp/app/exercises/quiz/manage/re-evaluate/drag-and-drop-question/re-evaluate-drag-and-drop-question.component.ts index 1ec89718bce0..4c84cd20b1c2 100644 --- a/src/main/webapp/app/exercises/quiz/manage/re-evaluate/drag-and-drop-question/re-evaluate-drag-and-drop-question.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/re-evaluate/drag-and-drop-question/re-evaluate-drag-and-drop-question.component.ts @@ -1,5 +1,6 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'; import { DragAndDropQuestion } from 'app/entities/quiz/drag-and-drop-question.model'; +import { DragAndDropQuestionEditComponent } from 'app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component'; @Component({ selector: 'jhi-re-evaluate-drag-and-drop-question', @@ -8,10 +9,13 @@ import { DragAndDropQuestion } from 'app/entities/quiz/drag-and-drop-question.mo [question]="question" [questionIndex]="questionIndex" [reEvaluationInProgress]="true" + [filePool]="fileMap" (questionUpdated)="questionUpdated.emit()" (questionDeleted)="questionDeleted.emit()" (questionMoveUp)="questionMoveUp.emit()" (questionMoveDown)="questionMoveDown.emit()" + (addNewFile)="handleAddFile($event)" + (removeFile)="handleRemoveFile($event)" > `, @@ -27,19 +31,40 @@ export class ReEvaluateDragAndDropQuestionComponent { onMoveDown: '&' */ + @ViewChild(DragAndDropQuestionEditComponent) + dragAndDropQuestionEditComponent: DragAndDropQuestionEditComponent; + @Input() question: DragAndDropQuestion; @Input() questionIndex: number; @Output() - questionUpdated = new EventEmitter(); + questionUpdated = new EventEmitter(); @Output() - questionDeleted = new EventEmitter(); + questionDeleted = new EventEmitter(); @Output() - questionMoveUp = new EventEmitter(); + questionMoveUp = new EventEmitter(); @Output() - questionMoveDown = new EventEmitter(); + questionMoveDown = new EventEmitter(); + + fileMap = new Map(); constructor() {} + + /** + * Add the given file to the fileMap for later upload. + * @param event the event containing the file and its name. The name provided may be different from the actual file name but has to correspond to the name set in the entity object. + */ + handleAddFile(event: { fileName: string; path?: string; file: File }): void { + this.fileMap.set(event.fileName, { path: event.path, file: event.file }); + } + + /** + * Remove the given file from the fileMap. + * @param fileName the name of the file to be removed + */ + handleRemoveFile(fileName: string): void { + this.fileMap.delete(fileName); + } } diff --git a/src/main/webapp/app/exercises/quiz/manage/re-evaluate/quiz-re-evaluate-warning.component.ts b/src/main/webapp/app/exercises/quiz/manage/re-evaluate/quiz-re-evaluate-warning.component.ts index 715f500a74da..6947cdbb1635 100644 --- a/src/main/webapp/app/exercises/quiz/manage/re-evaluate/quiz-re-evaluate-warning.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/re-evaluate/quiz-re-evaluate-warning.component.ts @@ -34,6 +34,8 @@ export class QuizReEvaluateWarningComponent implements OnInit { quizExercise: QuizExercise; backUpQuiz: QuizExercise; + files: Map; + // Icons faBan = faBan; faSpinner = faSpinner; @@ -250,7 +252,7 @@ export class QuizReEvaluateWarningComponent implements OnInit { confirmChange(): void { this.busy = true; - this.quizReEvaluateService.update(this.quizExercise).subscribe({ + this.quizReEvaluateService.reevaluate(this.quizExercise, this.files).subscribe({ next: () => { this.busy = false; this.successful = true; diff --git a/src/main/webapp/app/exercises/quiz/manage/re-evaluate/quiz-re-evaluate.component.ts b/src/main/webapp/app/exercises/quiz/manage/re-evaluate/quiz-re-evaluate.component.ts index df9c68ac197b..85827804f460 100644 --- a/src/main/webapp/app/exercises/quiz/manage/re-evaluate/quiz-re-evaluate.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/re-evaluate/quiz-re-evaluate.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectorRef, Component, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChildren, ViewEncapsulation } from '@angular/core'; import { Subscription } from 'rxjs'; import { ActivatedRoute } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; @@ -17,6 +17,7 @@ import { IncludedInOverallScore } from 'app/entities/exercise.model'; import { QuizExerciseValidationDirective } from 'app/exercises/quiz/manage/quiz-exercise-validation.directive'; import { ShortAnswerQuestionUtil } from 'app/exercises/quiz/shared/short-answer-question-util.service'; import { faExclamationCircle, faExclamationTriangle, faUndo } from '@fortawesome/free-solid-svg-icons'; +import { ReEvaluateDragAndDropQuestionComponent } from 'app/exercises/quiz/manage/re-evaluate/drag-and-drop-question/re-evaluate-drag-and-drop-question.component'; @Component({ selector: 'jhi-quiz-re-evaluate', @@ -28,6 +29,9 @@ import { faExclamationCircle, faExclamationTriangle, faUndo } from '@fortawesome export class QuizReEvaluateComponent extends QuizExerciseValidationDirective implements OnInit, OnChanges, OnDestroy { private subscription: Subscription; + @ViewChildren(ReEvaluateDragAndDropQuestionComponent) + reEvaluateDragAndDropQuestionComponents: ReEvaluateDragAndDropQuestionComponent[]; + modalService: NgbModal; popupService: QuizExercisePopupService; @@ -106,7 +110,13 @@ export class QuizReEvaluateComponent extends QuizExerciseValidationDirective imp * -> if canceled: close Modal */ save(): void { - this.popupService.open(QuizReEvaluateWarningComponent as Component, this.quizExercise).then((res) => { + const files = new Map(); + for (const component of this.reEvaluateDragAndDropQuestionComponents) { + component.fileMap.forEach((value, filename) => { + files.set(filename, value.file); + }); + } + this.popupService.open(QuizReEvaluateWarningComponent as Component, this.quizExercise, files).then((res) => { res.result.then(() => { this.savedEntity = cloneDeep(this.quizExercise); }); diff --git a/src/main/webapp/app/exercises/quiz/manage/re-evaluate/quiz-re-evaluate.service.ts b/src/main/webapp/app/exercises/quiz/manage/re-evaluate/quiz-re-evaluate.service.ts index 8e654cbecae6..8a863a6906b7 100644 --- a/src/main/webapp/app/exercises/quiz/manage/re-evaluate/quiz-re-evaluate.service.ts +++ b/src/main/webapp/app/exercises/quiz/manage/re-evaluate/quiz-re-evaluate.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { objectToJsonBlob } from 'app/utils/blob-util'; @Injectable({ providedIn: 'root' }) export class QuizReEvaluateService { @@ -9,15 +10,20 @@ export class QuizReEvaluateService { constructor(private http: HttpClient) {} - update(quizExercise: QuizExercise) { - const copy = QuizReEvaluateService.convert(quizExercise); - return this.http.put(this.resourceUrl + quizExercise.id + '/re-evaluate', copy, { observe: 'response' }); + reevaluate(quizExercise: QuizExercise, files: Map) { + const copy = this.convert(quizExercise); + const formData = new FormData(); + formData.append('exercise', objectToJsonBlob(copy)); + files.forEach((file, fileName) => { + formData.append('files', file, fileName); + }); + return this.http.put(this.resourceUrl + quizExercise.id + '/re-evaluate', formData, { observe: 'response' }); } /** * Copy the QuizExercise object */ - private static convert(quizExercise: QuizExercise): QuizExercise { + private convert(quizExercise: QuizExercise): QuizExercise { const copy: QuizExercise = Object.assign({}, quizExercise); copy.categories = ExerciseService.stringifyExerciseCategories(copy); return copy; diff --git a/src/main/webapp/app/shared/http/file-uploader.service.ts b/src/main/webapp/app/shared/http/file-uploader.service.ts index f3c5fc95af3a..b5d7e5c65226 100644 --- a/src/main/webapp/app/shared/http/file-uploader.service.ts +++ b/src/main/webapp/app/shared/http/file-uploader.service.ts @@ -59,20 +59,4 @@ export class FileUploaderService { formData.append('file', file, fileName); return lastValueFrom(this.http.post(endpoint + `?keepFileName=${keepFileName}`, formData)); } - - /** - * Duplicates file in the server. - * @param filePath Path of the file which needs to be duplicated - */ - async duplicateFile(filePath: string): Promise { - // Get file from the server using filePath, - const file = await lastValueFrom(this.http.get(filePath, { responseType: 'blob' })); - // Generate a temp file name with extension. File extension is necessary as server stores only specific kind of files, - const tempFilename = 'temp' + filePath.split('/').pop()!.split('#')[0].split('?')[0]; - const formData = new FormData(); - formData.append('file', file, tempFilename); - // Upload the file to server. This will make a new file in the server in the temp folder - // and will return path of the file, - return await lastValueFrom(this.http.post(`/api/fileUpload?keepFileName=${false}`, formData)); - } } diff --git a/src/main/webapp/app/shared/http/file.service.ts b/src/main/webapp/app/shared/http/file.service.ts index b1013c9dd322..06c11f920e1f 100644 --- a/src/main/webapp/app/shared/http/file.service.ts +++ b/src/main/webapp/app/shared/http/file.service.ts @@ -1,5 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { lastValueFrom } from 'rxjs'; +import { v4 as uuid } from 'uuid'; import { ProgrammingLanguage, ProjectType } from 'app/entities/programming-exercise.model'; @@ -23,6 +25,17 @@ export class FileService { return this.http.get(`${this.resourceUrl}/templates/` + urlParts.join('/'), { responseType: 'text' as 'json' }); } + /** + * Fetches the file from the given path and returns it as a File object with a unique file name. + * @param filePath path of the file + * @param mapOfFiles optional map to check if the generated file name already exists + */ + async getFile(filePath: string, mapOfFiles?: Map): Promise { + const blob = await lastValueFrom(this.http.get(filePath, { responseType: 'blob' })); + const file = new File([blob], this.getUniqueFileName(this.getExtension(filePath), mapOfFiles)); + return Promise.resolve(file); + } + /** * Downloads the file from the provided downloadUrl. * @@ -50,4 +63,27 @@ export class FileService { newWindow!.location.href = `api/files/attachments/lecture/${lectureId}/merge-pdf`; return newWindow; } + + /** + * Returns the file extension of the given filename. + * + * @param filename the filename + */ + getExtension(filename: string): string { + return filename.split('.').pop()!; + } + + /** + * Returns a unique file name with the given extension. + * + * @param extension the file extension to add + * @param mapOfFiles optional map to check if the generated file name already exists + */ + getUniqueFileName(extension: string, mapOfFiles?: Map): string { + let name; + do { + name = uuid() + '.' + extension; + } while (mapOfFiles && mapOfFiles.has(name)); + return name; + } } diff --git a/src/main/webapp/i18n/de/dragAndDropQuestion.json b/src/main/webapp/i18n/de/dragAndDropQuestion.json index 19f8fbc0502c..78fd8dd3f720 100644 --- a/src/main/webapp/i18n/de/dragAndDropQuestion.json +++ b/src/main/webapp/i18n/de/dragAndDropQuestion.json @@ -19,7 +19,7 @@ "dragItems": "Drag Items", "correctMappings": "Korrekte Zuordnungen", "randomizeOrder": "Drag Items in zufälliger Reihenfolge anzeigen", - "uploadBackgroundPicture": "Hintergrund Hochladen", + "selectBackgroundPicture": "Hintergrund Auswählen", "upload": "Hochladen", "uploading": "Lade hoch...", "disabledPreviewTooltip": "Um eine Vorschau zu zeigen, lade bitte zuerst ein Hintergrundbild hoch.", diff --git a/src/main/webapp/i18n/en/dragAndDropQuestion.json b/src/main/webapp/i18n/en/dragAndDropQuestion.json index 014bd61120ba..6e7131fc734f 100644 --- a/src/main/webapp/i18n/en/dragAndDropQuestion.json +++ b/src/main/webapp/i18n/en/dragAndDropQuestion.json @@ -19,7 +19,7 @@ "dragItems": "Drag Items", "correctMappings": "Correct Mappings", "randomizeOrder": "Present Drag Items in Random Order", - "uploadBackgroundPicture": "Upload Background", + "selectBackgroundPicture": "Select Background", "upload": "Upload", "uploading": "Uploading...", "disabledPreviewTooltip": "To show a preview, please upload a background picture first.", diff --git a/src/test/cypress/e2e/course/CourseExercise.cy.ts b/src/test/cypress/e2e/course/CourseExercise.cy.ts index d3842e8ea0cd..b49cf4a61c9f 100644 --- a/src/test/cypress/e2e/course/CourseExercise.cy.ts +++ b/src/test/cypress/e2e/course/CourseExercise.cy.ts @@ -23,13 +23,13 @@ describe('Course Exercise', () => { before('Create Exercises', () => { exerciseAPIRequest.createQuizExercise({ course }, [multipleChoiceQuizTemplate], 'Course Exercise Quiz 1').then((response) => { - exercise1 = response.body; + exercise1 = convertModelAfterMultiPart(response); }); exerciseAPIRequest.createQuizExercise({ course }, [multipleChoiceQuizTemplate], 'Course Exercise Quiz 2').then((response) => { - exercise2 = response.body; + exercise2 = convertModelAfterMultiPart(response); }); exerciseAPIRequest.createQuizExercise({ course }, [multipleChoiceQuizTemplate], 'Course Exercise 3').then((response) => { - exercise3 = response.body; + exercise3 = convertModelAfterMultiPart(response); }); }); diff --git a/src/test/cypress/e2e/exercises/ExerciseImport.cy.ts b/src/test/cypress/e2e/exercises/ExerciseImport.cy.ts index 2754e69f5b97..3443fb771ba4 100644 --- a/src/test/cypress/e2e/exercises/ExerciseImport.cy.ts +++ b/src/test/cypress/e2e/exercises/ExerciseImport.cy.ts @@ -43,7 +43,7 @@ describe('Import exercises', () => { textExercise = response.body; }); exerciseAPIRequest.createQuizExercise({ course }, [multipleChoiceQuizTemplate]).then((response) => { - quizExercise = response.body; + quizExercise = convertModelAfterMultiPart(response); }); exerciseAPIRequest.createModelingExercise({ course }).then((response) => { modelingExercise = response.body; diff --git a/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseAssessment.cy.ts b/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseAssessment.cy.ts index 6c7b593149d6..ebd2e247beda 100644 --- a/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseAssessment.cy.ts +++ b/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseAssessment.cy.ts @@ -1,6 +1,5 @@ import { Course } from 'app/entities/course.model'; import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; - import multipleChoiceQuizTemplate from '../../../fixtures/exercise/quiz/multiple_choice/template.json'; import shortAnswerQuizTemplate from '../../../fixtures/exercise/quiz/short_answer/template.json'; import { courseManagementAPIRequest, exerciseAPIRequest, exerciseResult } from '../../../support/artemis'; @@ -24,7 +23,7 @@ describe('Quiz Exercise Assessment', () => { before('Creates a quiz and a submission', () => { cy.login(admin); exerciseAPIRequest.createQuizExercise({ course }, [multipleChoiceQuizTemplate], undefined, undefined, 10).then((quizResponse) => { - quizExercise = quizResponse.body; + quizExercise = convertModelAfterMultiPart(quizResponse); exerciseAPIRequest.setQuizVisible(quizExercise.id!); exerciseAPIRequest.startQuizNow(quizExercise.id!); }); @@ -43,7 +42,7 @@ describe('Quiz Exercise Assessment', () => { before('Creates a quiz and a submission', () => { cy.login(admin); exerciseAPIRequest.createQuizExercise({ course }, [shortAnswerQuizTemplate], undefined, undefined, 10).then((quizResponse) => { - quizExercise = quizResponse.body; + quizExercise = convertModelAfterMultiPart(quizResponse); exerciseAPIRequest.setQuizVisible(quizExercise.id!); exerciseAPIRequest.startQuizNow(quizExercise.id!); }); diff --git a/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseDropLocation.cy.ts b/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseDropLocation.cy.ts index 85dfab347a16..efa6cd1777aa 100644 --- a/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseDropLocation.cy.ts +++ b/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseDropLocation.cy.ts @@ -6,6 +6,7 @@ import { convertModelAfterMultiPart } from '../../../support/utils'; let course: Course; +// TODO: fix this test. Fails only on CI. Locally it works and manual testing also works. describe.skip('Quiz Exercise Drop Location Spec', () => { before('Create course', () => { cy.login(admin); diff --git a/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseManagement.cy.ts b/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseManagement.cy.ts index 16a3a6c43c3a..6d406de11024 100644 --- a/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseManagement.cy.ts +++ b/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseManagement.cy.ts @@ -1,8 +1,6 @@ import { Interception } from 'cypress/types/net-stubbing'; - import { Course } from 'app/entities/course.model'; import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; - import multipleChoiceTemplate from '../../../fixtures/exercise/quiz/multiple_choice/template.json'; import { courseManagement, courseManagementAPIRequest, courseManagementExercises, exerciseAPIRequest, navigationBar, quizExerciseCreation } from '../../../support/artemis'; import { admin } from '../../../support/users'; @@ -60,7 +58,7 @@ describe('Quiz Exercise Management', () => { before('Create quiz Exercise', () => { cy.login(admin); exerciseAPIRequest.createQuizExercise({ course }, [multipleChoiceTemplate]).then((quizResponse) => { - quizExercise = quizResponse.body; + quizExercise = convertModelAfterMultiPart(quizResponse); }); }); diff --git a/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseParticipation.cy.ts b/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseParticipation.cy.ts index 13a74f780a44..391cf81bb551 100644 --- a/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseParticipation.cy.ts +++ b/src/test/cypress/e2e/exercises/quiz-exercise/QuizExerciseParticipation.cy.ts @@ -1,6 +1,5 @@ import { Course } from 'app/entities/course.model'; import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; - import multipleChoiceQuizTemplate from '../../../fixtures/exercise/quiz/multiple_choice/template.json'; import shortAnswerQuizTemplate from '../../../fixtures/exercise/quiz/short_answer/template.json'; import { courseManagementAPIRequest, courseOverview, exerciseAPIRequest, quizExerciseMultipleChoice, quizExerciseShortAnswerQuiz } from '../../../support/artemis'; @@ -23,7 +22,7 @@ describe('Quiz Exercise Participation', () => { beforeEach('Create quiz exercise', () => { cy.login(admin); exerciseAPIRequest.createQuizExercise({ course }, [multipleChoiceQuizTemplate]).then((quizResponse) => { - quizExercise = quizResponse.body; + quizExercise = convertModelAfterMultiPart(quizResponse); }); }); @@ -55,7 +54,7 @@ describe('Quiz Exercise Participation', () => { before('Create SA quiz', () => { cy.login(admin); exerciseAPIRequest.createQuizExercise({ course }, [shortAnswerQuizTemplate]).then((quizResponse) => { - quizExercise = quizResponse.body; + quizExercise = convertModelAfterMultiPart(quizResponse); exerciseAPIRequest.setQuizVisible(quizExercise.id!); exerciseAPIRequest.startQuizNow(quizExercise.id!); }); diff --git a/src/test/cypress/support/pageobjects/exam/ExamExerciseGroupCreationPage.ts b/src/test/cypress/support/pageobjects/exam/ExamExerciseGroupCreationPage.ts index e338fafe74a2..65caa585776c 100644 --- a/src/test/cypress/support/pageobjects/exam/ExamExerciseGroupCreationPage.ts +++ b/src/test/cypress/support/pageobjects/exam/ExamExerciseGroupCreationPage.ts @@ -5,6 +5,8 @@ import { examAPIRequests, exerciseAPIRequest } from '../../artemis'; import { AdditionalData, BASE_API, Exercise, ExerciseType, PUT } from '../../constants'; import { POST } from '../../constants'; import { generateUUID } from '../../utils'; +import { convertModelAfterMultiPart } from '../../requests/CourseManagementRequests'; +import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; /** * A class which encapsulates UI selectors and actions for the exam exercise group creation page. @@ -33,10 +35,12 @@ export class ExamExerciseGroupCreationPage { addGroupWithExercise(exam: Exam, exerciseType: ExerciseType, additionalData: AdditionalData = {}): Promise { return new Promise((resolve) => { this.handleAddGroupWithExercise(exam, 'Exercise ' + generateUUID(), exerciseType, additionalData, (response) => { - if (exerciseType == ExerciseType.QUIZ) { - additionalData!.quizExerciseID = response.body.quizQuestions![0].id; + let exercise = { ...response.body, additionalData }; + if (exerciseType == EXERCISE_TYPE.Quiz) { + const quiz = convertModelAfterMultiPart(response) as QuizExercise; + additionalData!.quizExerciseID = quiz.quizQuestions![0].id; + exercise = { ...quiz, additionalData }; } - const exercise = { ...response.body, additionalData }; resolve(exercise); }); }); diff --git a/src/test/cypress/support/pageobjects/exercises/text/TextEditorPage.ts b/src/test/cypress/support/pageobjects/exercises/text/TextEditorPage.ts index 8a3ed4bacb5f..faf7b134ccd9 100644 --- a/src/test/cypress/support/pageobjects/exercises/text/TextEditorPage.ts +++ b/src/test/cypress/support/pageobjects/exercises/text/TextEditorPage.ts @@ -30,7 +30,7 @@ export class TextEditorPage { submit() { cy.intercept(PUT, BASE_API + 'exercises/*/text-submissions').as('textSubmission'); - cy.get('#submit').click(); + cy.get('#submit button').click(); return cy.wait('@textSubmission'); } diff --git a/src/test/cypress/support/requests/ExerciseAPIRequests.ts b/src/test/cypress/support/requests/ExerciseAPIRequests.ts index 1f0089664f18..533b044558ac 100644 --- a/src/test/cypress/support/requests/ExerciseAPIRequests.ts +++ b/src/test/cypress/support/requests/ExerciseAPIRequests.ts @@ -373,10 +373,14 @@ export class ExerciseAPIRequests { } else { newQuizExercise = Object.assign({}, quizExercise, body); } + + const formData = new FormData(); + formData.append('exercise', new File([JSON.stringify(newQuizExercise)], 'exercise', { type: 'application/json' })); + return cy.request({ url: QUIZ_EXERCISE_BASE, method: POST, - body: newQuizExercise, + body: formData, }); } diff --git a/src/test/cypress/support/utils.ts b/src/test/cypress/support/utils.ts index 1ba991133e1d..4449f7b453de 100644 --- a/src/test/cypress/support/utils.ts +++ b/src/test/cypress/support/utils.ts @@ -1,5 +1,4 @@ import { TIME_FORMAT } from './constants'; -import { Course } from 'app/entities/course.model'; import dayjs from 'dayjs/esm'; import utc from 'dayjs/esm/plugin/utc'; import { v4 as uuidv4 } from 'uuid'; @@ -46,11 +45,11 @@ export function dayjsToString(day: dayjs.Dayjs) { } /** - * Converts the response object obtained from a multipart request to a Course object. - * @param response - The Cypress.Response object obtained from a multipart request. - * @returns The Course object parsed from the response. + * Converts the response object obtained from a multipart request to an entity object. + * @param response - The Cypress.Response object obtained from a multipart request. + * @returns The entity object parsed from the response. */ -export function convertModelAfterMultiPart(response: Cypress.Response): Course { +export function convertModelAfterMultiPart(response: Cypress.Response): T { // Cypress currently has some issues with our multipart request, parsing this not as an object but as an ArrayBuffer // Once this is fixed (and hence the expect statements below fail), we can remove the additional parsing expect(response.body).not.to.be.an('object'); diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/fileuploadexercise/FileUploadSubmissionIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exercise/fileuploadexercise/FileUploadSubmissionIntegrationTest.java index 99c5e9065fad..4b622681c31e 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/fileuploadexercise/FileUploadSubmissionIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/fileuploadexercise/FileUploadSubmissionIntegrationTest.java @@ -167,7 +167,7 @@ else if (filename.contains("\\")) { } } - URI publicFilePath = filePathService.publicPathForActualPath(actualFilePath, returnedSubmission.getId()); + URI publicFilePath = filePathService.publicPathForActualPathOrThrow(actualFilePath, returnedSubmission.getId()); assertThat(returnedSubmission).as("submission correctly posted").isNotNull(); assertThat(returnedSubmission.getFilePath()).isEqualTo(publicFilePath.toString()); var fileBytes = Files.readAllBytes(actualFilePath); diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseFactory.java b/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseFactory.java index 2b6fe03b6a68..e35dcc77a67d 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseFactory.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseFactory.java @@ -117,19 +117,24 @@ public static DragAndDropQuestion createDragAndDropQuestion() { dropLocation2.setTempID(generateTempId()); var dropLocation3 = new DropLocation().posX(30d).posY(30d).height(10d).width(10d); dropLocation3.setTempID(generateTempId()); + var dropLocation4 = new DropLocation().posX(40d).posY(40d).height(10d).width(10d); + dropLocation4.setTempID(generateTempId()); dnd.addDropLocation(dropLocation1); // also invoke remove once dnd.removeDropLocation(dropLocation1); dnd.addDropLocation(dropLocation1); dnd.addDropLocation(dropLocation2); dnd.addDropLocation(dropLocation3); + dnd.addDropLocation(dropLocation4); var dragItem1 = new DragItem().text("D1"); dragItem1.setTempID(generateTempId()); - var dragItem2 = new DragItem().text("D2"); + var dragItem2 = new DragItem().pictureFilePath("dragItemImage2.png"); dragItem2.setTempID(generateTempId()); var dragItem3 = new DragItem().text("D3"); dragItem3.setTempID(generateTempId()); + var dragItem4 = new DragItem().pictureFilePath("dragItemImage4.png"); + dragItem4.setTempID(generateTempId()); dnd.addDragItem(dragItem1); assertThat(dragItem1.getQuestion()).isEqualTo(dnd); // also invoke remove once @@ -137,6 +142,7 @@ public static DragAndDropQuestion createDragAndDropQuestion() { dnd.addDragItem(dragItem1); dnd.addDragItem(dragItem2); dnd.addDragItem(dragItem3); + dnd.addDragItem(dragItem4); var mapping1 = new DragAndDropMapping().dragItem(dragItem1).dropLocation(dropLocation1); dragItem1.addMappings(mapping1); @@ -152,6 +158,8 @@ public static DragAndDropQuestion createDragAndDropQuestion() { dnd.addCorrectMapping(mapping2); var mapping3 = new DragAndDropMapping().dragItem(dragItem3).dropLocation(dropLocation3); dnd.addCorrectMapping(mapping3); + var mapping4 = new DragAndDropMapping().dragItem(dragItem4).dropLocation(dropLocation4); + dnd.addCorrectMapping(mapping4); dnd.setExplanation("Explanation"); // invoke some util methods @@ -237,6 +245,9 @@ else if (question instanceof DragAndDropQuestion) { DragItem dragItem3 = ((DragAndDropQuestion) question).getDragItems().get(2); dragItem3.setQuestion((DragAndDropQuestion) question); + DragItem dragItem4 = ((DragAndDropQuestion) question).getDragItems().get(3); + dragItem4.setQuestion((DragAndDropQuestion) question); + DropLocation dropLocation1 = ((DragAndDropQuestion) question).getDropLocations().get(0); dropLocation1.setQuestion((DragAndDropQuestion) question); @@ -246,15 +257,20 @@ else if (question instanceof DragAndDropQuestion) { DropLocation dropLocation3 = ((DragAndDropQuestion) question).getDropLocations().get(2); dropLocation3.setQuestion((DragAndDropQuestion) question); + DropLocation dropLocation4 = ((DragAndDropQuestion) question).getDropLocations().get(3); + dropLocation4.setQuestion((DragAndDropQuestion) question); + if (correct) { submittedAnswer.addMappings(new DragAndDropMapping().dragItem(dragItem1).dropLocation(dropLocation1)); submittedAnswer.addMappings(new DragAndDropMapping().dragItem(dragItem2).dropLocation(dropLocation2)); submittedAnswer.addMappings(new DragAndDropMapping().dragItem(dragItem3).dropLocation(dropLocation3)); + submittedAnswer.addMappings(new DragAndDropMapping().dragItem(dragItem4).dropLocation(dropLocation4)); } else { submittedAnswer.addMappings(new DragAndDropMapping().dragItem(dragItem2).dropLocation(dropLocation3)); - submittedAnswer.addMappings(new DragAndDropMapping().dragItem(dragItem1).dropLocation(dropLocation2)); + submittedAnswer.addMappings(new DragAndDropMapping().dragItem(dragItem1).dropLocation(dropLocation4)); submittedAnswer.addMappings(new DragAndDropMapping().dragItem(dragItem3).dropLocation(dropLocation1)); + submittedAnswer.addMappings(new DragAndDropMapping().dragItem(dragItem4).dropLocation(dropLocation2)); } return submittedAnswer; diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseIntegrationTest.java index 4dbd1b9914c5..f0855a3b733c 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizExerciseIntegrationTest.java @@ -2,11 +2,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.byLessThan; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.*; -import java.util.concurrent.ScheduledThreadPoolExecutor; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,14 +15,25 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockMultipartHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import com.fasterxml.jackson.databind.ObjectMapper; import de.tum.in.www1.artemis.AbstractSpringIntegrationIndependentTest; import de.tum.in.www1.artemis.course.CourseUtilService; import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.Team; +import de.tum.in.www1.artemis.domain.TeamAssignmentConfig; import de.tum.in.www1.artemis.domain.enumeration.*; import de.tum.in.www1.artemis.domain.exam.ExerciseGroup; import de.tum.in.www1.artemis.domain.metis.conversation.Channel; @@ -33,6 +44,7 @@ import de.tum.in.www1.artemis.repository.*; import de.tum.in.www1.artemis.repository.metis.conversation.ChannelRepository; import de.tum.in.www1.artemis.security.SecurityUtils; +import de.tum.in.www1.artemis.service.ExerciseService; import de.tum.in.www1.artemis.service.QuizExerciseService; import de.tum.in.www1.artemis.user.UserUtilService; import de.tum.in.www1.artemis.util.ExerciseIntegrationTestUtils; @@ -70,6 +82,12 @@ class QuizExerciseIntegrationTest extends AbstractSpringIntegrationIndependentTe @Autowired private ExerciseIntegrationTestUtils exerciseIntegrationTestUtils; + @Autowired + private ExerciseService exerciseService; + + @Autowired + private ObjectMapper objectMapper; + @Autowired private ChannelRepository channelRepository; @@ -147,14 +165,99 @@ void createQuizExercise_setCourseAndExerciseGroup_badRequest() throws Exception QuizExercise quizExercise = QuizExerciseFactory.generateQuizExerciseForExam(exerciseGroup); quizExercise.setCourse(exerciseGroup.getExam().getCourse()); - request.postWithResponseBody("/api/quiz-exercises/", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + createQuizExerciseWithFiles(quizExercise, HttpStatus.BAD_REQUEST, true); + } + + private QuizExercise importQuizExerciseWithFiles(QuizExercise quizExercise, Long id, List files, HttpStatus expectedStatus) throws Exception { + var builder = MockMvcRequestBuilders.multipart(HttpMethod.POST, "/api/quiz-exercises/import/" + id); + builder.file(new MockMultipartFile("exercise", "", MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsBytes(quizExercise))) + .contentType(MediaType.MULTIPART_FORM_DATA); + for (MockMultipartFile file : files) { + builder.file(file); + } + MvcResult result = request.getMvc().perform(builder).andExpect(status().is(expectedStatus.value())).andReturn(); + request.restoreSecurityContext(); + if (expectedStatus == HttpStatus.CREATED) { + return objectMapper.readValue(result.getResponse().getContentAsString(), QuizExercise.class); + } + return null; + } + + private QuizExercise reevalQuizExerciseWithFiles(QuizExercise quizExercise, Long id, List files, HttpStatus expectedStatus) throws Exception { + var builder = MockMvcRequestBuilders.multipart(HttpMethod.PUT, "/api/quiz-exercises/" + id + "/re-evaluate"); + builder.file(new MockMultipartFile("exercise", "", MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsBytes(quizExercise))) + .contentType(MediaType.MULTIPART_FORM_DATA); + for (MockMultipartFile file : files) { + builder.file(file); + } + MvcResult result = request.getMvc().perform(builder).andExpect(status().is(expectedStatus.value())).andReturn(); + request.restoreSecurityContext(); + if (expectedStatus == HttpStatus.OK) { + return objectMapper.readValue(result.getResponse().getContentAsString(), QuizExercise.class); + } + return null; + } + + /** + * Sends a create request for a quiz exercise. It automatically adds the files to the request and modifies the exercise to be sent. + * + * @param quizExercise the quiz exercise to be sent + * @param expectedStatus the expected status of the request + * @param addBackgroundImage whether to add a background image to the quiz exercise + * @return the created quiz exercise or null if the request failed + */ + private QuizExercise createQuizExerciseWithFiles(QuizExercise quizExercise, HttpStatus expectedStatus, boolean addBackgroundImage) throws Exception { + var builder = MockMvcRequestBuilders.multipart(HttpMethod.POST, "/api/quiz-exercises"); + addFilesToBuilderAndModifyExercise(builder, quizExercise, addBackgroundImage); + builder.file(new MockMultipartFile("exercise", "", MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsBytes(quizExercise))) + .contentType(MediaType.MULTIPART_FORM_DATA); + MvcResult result = request.getMvc().perform(builder).andExpect(status().is(expectedStatus.value())).andReturn(); + request.restoreSecurityContext(); + if (HttpStatus.valueOf(result.getResponse().getStatus()).is2xxSuccessful()) { + assertThat(result.getResponse().getContentAsString()).isNotBlank(); + return objectMapper.readValue(result.getResponse().getContentAsString(), QuizExercise.class); + } + return null; + } + + private QuizExercise updateQuizExerciseWithFiles(QuizExercise quizExercise, List fileNames, HttpStatus expectedStatus) throws Exception { + return updateQuizExerciseWithFiles(quizExercise, fileNames, expectedStatus, null); + } + + /** + * Sends an update request for the given quiz exercise. + * + * @param quizExercise the quiz exercise to update. Expects to contain changed filenames if appliccable + * @param fileNames the filenames of changed or new files + * @param expectedStatus the expected status of the request + * @return the updated quiz exercise or null if the request failed + */ + private QuizExercise updateQuizExerciseWithFiles(QuizExercise quizExercise, List fileNames, HttpStatus expectedStatus, MultiValueMap params) + throws Exception { + var builder = MockMvcRequestBuilders.multipart(HttpMethod.PUT, "/api/quiz-exercises/" + quizExercise.getId()); + if (params != null) { + builder.params(params); + } + if (fileNames != null) { + for (String fileName : fileNames) { + builder.file(new MockMultipartFile("files", fileName, MediaType.IMAGE_PNG_VALUE, "test".getBytes())); + } + } + builder.file(new MockMultipartFile("exercise", "", MediaType.APPLICATION_JSON_VALUE, objectMapper.writeValueAsBytes(quizExercise))) + .contentType(MediaType.MULTIPART_FORM_DATA); + MvcResult result = request.getMvc().perform(builder).andExpect(status().is(expectedStatus.value())).andReturn(); + request.restoreSecurityContext(); + if (HttpStatus.valueOf(result.getResponse().getStatus()).is2xxSuccessful()) { + return objectMapper.readValue(result.getResponse().getContentAsString(), QuizExercise.class); + } + return null; } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void createQuizExercise_setNeitherCourseAndExerciseGroup_badRequest() throws Exception { QuizExercise quizExercise = QuizExerciseFactory.generateQuizExerciseForExam(null); - request.postWithResponseBody("/api/quiz-exercises/", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + createQuizExerciseWithFiles(quizExercise, HttpStatus.BAD_REQUEST, true); } @Test @@ -163,7 +266,7 @@ void createQuizExercise_InvalidMaxScore() throws Exception { QuizExercise quizExercise = quizExerciseUtilService.createQuiz(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); quizExercise.setMaxPoints(0.0); - request.postWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + createQuizExerciseWithFiles(quizExercise, HttpStatus.BAD_REQUEST, true); } @Test @@ -172,7 +275,7 @@ void createQuizExercise_InvalidDates_badRequest() throws Exception { QuizExercise quizExercise = quizExerciseUtilService.createQuiz(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); quizExercise.getQuizBatches().forEach(batch -> batch.setStartTime(ZonedDateTime.now())); - request.postWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + createQuizExerciseWithFiles(quizExercise, HttpStatus.BAD_REQUEST, true); } @Test @@ -184,7 +287,7 @@ void createQuizExercise_IncludedAsBonusInvalidBonusPoints() throws Exception { quizExercise.setBonusPoints(1.0); quizExercise.setIncludedInOverallScore(IncludedInOverallScore.INCLUDED_AS_BONUS); - request.postWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + createQuizExerciseWithFiles(quizExercise, HttpStatus.BAD_REQUEST, true); } @Test @@ -196,7 +299,7 @@ void createQuizExercise_NotIncludedInvalidBonusPoints() throws Exception { quizExercise.setBonusPoints(1.0); quizExercise.setIncludedInOverallScore(IncludedInOverallScore.NOT_INCLUDED); - request.postWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + createQuizExerciseWithFiles(quizExercise, HttpStatus.BAD_REQUEST, true); } @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") @@ -224,7 +327,7 @@ void testUpdateQuizExercise_SingleChoiceMC_AllOrNothing() throws Exception { mc.setSingleChoice(true); mc.getAnswerOptions().get(1).setIsCorrect(true); - request.putWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + updateQuizExerciseWithFiles(quizExercise, List.of(), HttpStatus.BAD_REQUEST); } @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") @@ -237,59 +340,81 @@ void testUpdateQuizExercise_SingleChoiceMC_badRequest(ScoringType scoringType) t mc.setSingleChoice(true); mc.setScoringType(scoringType); - request.putWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + updateQuizExerciseWithFiles(quizExercise, List.of(), HttpStatus.BAD_REQUEST); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void updateQuizExercise_setCourseAndExerciseGroup_badRequest() throws Exception { ExerciseGroup exerciseGroup = examUtilService.createAndSaveActiveExerciseGroup(true); - QuizExercise quizExercise = quizExerciseUtilService.createAndSaveQuiz(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); + QuizExercise quizExercise = createQuizOnServer(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); quizExercise.setExerciseGroup(exerciseGroup); - request.putWithResponseBody("/api/quiz-exercises/", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + updateQuizExerciseWithFiles(quizExercise, List.of(), HttpStatus.BAD_REQUEST); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void updateQuizExercise_setNeitherCourseAndExerciseGroup_badRequest() throws Exception { - QuizExercise quizExercise = quizExerciseUtilService.createAndSaveQuiz(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); + QuizExercise quizExercise = createQuizOnServer(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); quizExercise.setCourse(null); - request.putWithResponseBody("/api/quiz-exercises/", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + updateQuizExerciseWithFiles(quizExercise, List.of(), HttpStatus.BAD_REQUEST); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void updateQuizExercise_invalidDates_badRequest() throws Exception { - QuizExercise quizExercise = quizExerciseUtilService.createAndSaveQuiz(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); + QuizExercise quizExercise = createQuizOnServer(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); quizExercise.getQuizBatches().forEach(batch -> batch.setStartTime(ZonedDateTime.now())); - - request.putWithResponseBody("/api/quiz-exercises/", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + updateQuizExerciseWithFiles(quizExercise, List.of(), HttpStatus.BAD_REQUEST); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void updateQuizExercise_convertFromCourseToExamExercise_badRequest() throws Exception { - QuizExercise quizExercise = quizExerciseUtilService.createAndSaveQuiz(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); + QuizExercise quizExercise = createQuizOnServer(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); ExerciseGroup exerciseGroup = examUtilService.createAndSaveActiveExerciseGroup(true); quizExercise.setCourse(null); quizExercise.setExerciseGroup(exerciseGroup); - request.putWithResponseBody("/api/quiz-exercises/", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + updateQuizExerciseWithFiles(quizExercise, List.of(), HttpStatus.BAD_REQUEST); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void updateQuizExercise_convertFromExamToCourseExercise_badRequest() throws Exception { - QuizExercise quizExercise = quizExerciseUtilService.createAndSaveExamQuiz(ZonedDateTime.now().plusDays(1), ZonedDateTime.now().plusDays(2)); + QuizExercise quizExercise = createQuizOnServerForExam(); Course course = courseUtilService.addEmptyCourse(); quizExercise.setExerciseGroup(null); quizExercise.setCourse(course); - request.putWithResponseBody("/api/quiz-exercises/", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + updateQuizExerciseWithFiles(quizExercise, List.of(), HttpStatus.BAD_REQUEST); + } + + private void addFilesToBuilderAndModifyExercise(MockMultipartHttpServletRequestBuilder builder, QuizExercise quizExercise, boolean addBackgroundImage) { + int index = 0; + for (var question : quizExercise.getQuizQuestions()) { + if (question instanceof DragAndDropQuestion dragAndDropQuestion) { + if (addBackgroundImage) { + String backgroundFileName = "backgroundImage" + index++ + ".jpg"; + dragAndDropQuestion.setBackgroundFilePath(backgroundFileName); + builder.file(new MockMultipartFile("files", backgroundFileName, MediaType.IMAGE_JPEG_VALUE, "backgroundImage".getBytes())); + } + else { + dragAndDropQuestion.setBackgroundFilePath(null); + } + for (var dragItem : dragAndDropQuestion.getDragItems()) { + if (dragItem.getPictureFilePath() != null) { + String filename = "dragItemImage" + index++ + ".png"; + dragItem.setPictureFilePath(filename); + builder.file(new MockMultipartFile("files", filename, MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes())); + } + } + } + } } @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") @@ -335,22 +460,6 @@ void testDeleteQuizExerciseWithSubmittedAnswers(QuizMode quizMode) throws Except assertThat(quizExerciseRepository.findOneWithQuestionsAndStatistics(quizExercise.getId())).as("Exercise is deleted correctly").isNull(); } - @Test - @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void testUpdateNotExistingQuizExercise() throws Exception { - QuizExercise quizExercise = quizExerciseUtilService.createQuiz(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); - quizExercise.setChannelName("testchannel-quiz"); - QuizExercise quizExerciseServer = request.putWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.CREATED); - - assertThat(quizExerciseServer).usingRecursiveComparison().ignoringFieldsOfTypes(ZonedDateTime.class, ScheduledThreadPoolExecutor.class) - .ignoringFields("id", "quizPointStatistic", "quizQuestions", "quizBatches").isEqualTo(quizExercise); - - assertThat(quizExerciseServer.getId()).isNotNull(); - assertThat(quizExerciseServer.getQuizPointStatistic()).isNotNull(); - assertThat(quizExerciseServer.getQuizQuestions()).hasSameSizeAs(quizExercise.getQuizQuestions()); - assertThat(quizExerciseServer.getQuizBatches()).hasSameSizeAs(quizExercise.getQuizBatches()); - } - @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testUpdateRunningQuizExercise() throws Exception { @@ -360,16 +469,16 @@ void testUpdateRunningQuizExercise() throws Exception { mc.getAnswerOptions().remove(0); mc.getAnswerOptions().add(new AnswerOption().text("C").hint("H3").explanation("E3").isCorrect(true)); - QuizExercise updatedQuizExercise = request.putWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + QuizExercise updatedQuizExercise = updateQuizExerciseWithFiles(quizExercise, List.of(), HttpStatus.BAD_REQUEST); assertThat(updatedQuizExercise).isNull(); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testCreateExistingQuizExercise() throws Exception { - QuizExercise quizExercise = quizExerciseUtilService.createAndSaveQuiz(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); + QuizExercise quizExercise = createQuizOnServer(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); - request.postWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + createQuizExerciseWithFiles(quizExercise, HttpStatus.BAD_REQUEST, true); } @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") @@ -498,7 +607,6 @@ void testInstructorSearchTermMatchesId() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testCourseAndExamFiltersAsInstructor() throws Exception { - QuizExercise quizExercise = quizExerciseUtilService.createAndSaveQuiz(ZonedDateTime.now().minusDays(2), ZonedDateTime.now().minusHours(1), QuizMode.SYNCHRONIZED); QuizExercise examQuizExercise = quizExerciseUtilService.createAndSaveExamQuiz(ZonedDateTime.now().minusDays(1), ZonedDateTime.now().minusHours(2)); @@ -516,7 +624,7 @@ void testRecalculateStatistics() throws Exception { quizExercise.setReleaseDate(ZonedDateTime.now().minusHours(5)); quizExercise.setDueDate(ZonedDateTime.now().minusHours(2)); - quizExercise = request.putWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.OK); + quizExercise = updateQuizExerciseWithFiles(quizExercise, List.of(), HttpStatus.OK); var now = ZonedDateTime.now(); @@ -537,20 +645,7 @@ void testRecalculateStatistics() throws Exception { assertThat(quizExerciseWithRecalculatedStatistics.getQuizPointStatistic().getPointCounters()).hasSize(10); assertThat(quizExerciseWithRecalculatedStatistics.getQuizPointStatistic().getParticipantsRated()).isEqualTo(numberOfParticipants); - for (PointCounter pointCounter : quizExerciseWithRecalculatedStatistics.getQuizPointStatistic().getPointCounters()) { - if (pointCounter.getPoints().equals(0.0)) { - assertThat(pointCounter.getRatedCounter()).isEqualTo(3); - } - else if (pointCounter.getPoints().equals(3.0) || pointCounter.getPoints().equals(4.0) || pointCounter.getPoints().equals(6.0)) { - assertThat(pointCounter.getRatedCounter()).isEqualTo(2); - } - else if (pointCounter.getPoints().equals(7.0)) { - assertThat(pointCounter.getRatedCounter()).isEqualTo(1); - } - else { - assertThat(pointCounter.getRatedCounter()).isZero(); - } - } + assertQuizPointStatisticsPointCounters(quizExerciseWithRecalculatedStatistics, Map.of(0.0, pc30, 3.0, pc20, 4.0, pc20, 6.0, pc20, 7.0, pc10)); // add more submissions and recalculate for (int i = numberOfParticipants; i <= 14; i++) { @@ -565,26 +660,7 @@ else if (pointCounter.getPoints().equals(7.0)) { assertThat(quizExerciseWithRecalculatedStatistics.getQuizPointStatistic().getPointCounters()).hasSize(10); assertThat(quizExerciseWithRecalculatedStatistics.getQuizPointStatistic().getParticipantsRated()).isEqualTo(numberOfParticipants + 4); - for (PointCounter pointCounter : quizExerciseWithRecalculatedStatistics.getQuizPointStatistic().getPointCounters()) { - if (pointCounter.getPoints().equals(0.0)) { - assertThat(pointCounter.getRatedCounter()).isEqualTo(5); - } - else if (pointCounter.getPoints().equals(4.0)) { - assertThat(pointCounter.getRatedCounter()).isEqualTo(3); - } - else if (pointCounter.getPoints().equals(3.0) || pointCounter.getPoints().equals(6.0)) { - assertThat(pointCounter.getRatedCounter()).isEqualTo(2); - } - else if (pointCounter.getPoints().equals(7.0)) { - assertThat(pointCounter.getRatedCounter()).isEqualTo(1); - } - else if (pointCounter.getPoints().equals(9.0)) { - assertThat(pointCounter.getRatedCounter()).isEqualTo(1); - } - else { - assertThat(pointCounter.getRatedCounter()).isZero(); - } - } + assertQuizPointStatisticsPointCounters(quizExerciseWithRecalculatedStatistics, Map.of(0.0, pc50, 3.0, pc20, 4.0, pc30, 6.0, pc20, 7.0, pc10, 9.0, pc10)); } @Test @@ -593,10 +669,10 @@ void testReevaluateStatistics() throws Exception { QuizExercise quizExercise = createQuizOnServer(ZonedDateTime.now().plusSeconds(5), null, QuizMode.SYNCHRONIZED); // we expect a bad request because the quiz has not ended yet - request.putWithResponseBody("/api/quiz-exercises/" + quizExercise.getId() + "/re-evaluate/", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + reevalQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.BAD_REQUEST); quizExercise.setReleaseDate(ZonedDateTime.now().minusHours(5)); quizExerciseService.endQuiz(quizExercise, ZonedDateTime.now().minusMinutes(1)); - quizExercise = request.putWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.OK); + quizExercise = updateQuizExerciseWithFiles(quizExercise, List.of(), HttpStatus.OK); // generate rated submissions for each student int numberOfParticipants = 10; @@ -635,8 +711,7 @@ void testReevaluateStatistics() throws Exception { assertQuizPointStatisticsPointCounters(quizExercise, Map.of(0.0, pc30, 3.0, pc20, 4.0, pc20, 6.0, pc20, 7.0, pc10)); // reevaluate without changing anything and check if statistics are still correct (i.e. unchanged) - QuizExercise quizExerciseWithReevaluatedStatistics = request.putWithResponseBody("/api/quiz-exercises/" + quizExercise.getId() + "/re-evaluate/", quizExercise, - QuizExercise.class, HttpStatus.OK); + QuizExercise quizExerciseWithReevaluatedStatistics = reevalQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.OK); checkStatistics(quizExercise, quizExerciseWithReevaluatedStatistics); log.debug("QuizPointStatistic after re-evaluate (without changes): {}", quizExerciseWithReevaluatedStatistics.getQuizPointStatistic()); @@ -645,7 +720,7 @@ void testReevaluateStatistics() throws Exception { var multipleChoiceQuestion = (MultipleChoiceQuestion) quizExercise.getQuizQuestions().get(0); multipleChoiceQuestion.getAnswerOptions().remove(1); - request.putWithResponseBody("/api/quiz-exercises/" + quizExercise.getId() + "/re-evaluate/", quizExercise, QuizExercise.class, HttpStatus.OK); + reevalQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.OK); // load the exercise again after it was re-evaluated quizExerciseWithReevaluatedStatistics = request.get("/api/quiz-exercises/" + quizExercise.getId(), HttpStatus.OK, QuizExercise.class); @@ -665,7 +740,7 @@ void testReevaluateStatistics() throws Exception { var shortAnswerQuestion = (ShortAnswerQuestion) quizExercise.getQuizQuestions().get(2); shortAnswerQuestion.setInvalid(true); - request.putWithResponseBody("/api/quiz-exercises/" + quizExercise.getId() + "/re-evaluate/", quizExercise, QuizExercise.class, HttpStatus.OK); + reevalQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.OK); // load the exercise again after it was re-evaluated quizExerciseWithReevaluatedStatistics = request.get("/api/quiz-exercises/" + quizExercise.getId(), HttpStatus.OK, QuizExercise.class); @@ -680,7 +755,7 @@ void testReevaluateStatistics() throws Exception { // delete a question and reevaluate quizExercise.getQuizQuestions().remove(1); - request.putWithResponseBody("/api/quiz-exercises/" + quizExercise.getId() + "/re-evaluate/", quizExercise, QuizExercise.class, HttpStatus.OK); + reevalQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.OK); // load the exercise again after it was re-evaluated quizExerciseWithReevaluatedStatistics = request.get("/api/quiz-exercises/" + quizExercise.getId(), HttpStatus.OK, QuizExercise.class); @@ -702,12 +777,12 @@ void testReevaluateStatistics_Practice() throws Exception { quizExercise.getQuizQuestions().get(2).setScoringType(ScoringType.PROPORTIONAL_WITH_PENALTY); // SA // we expect a bad request because the quiz has not ended yet - request.putWithResponseBody("/api/quiz-exercises/" + quizExercise.getId() + "/re-evaluate/", quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + reevalQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.BAD_REQUEST); quizExercise.setReleaseDate(ZonedDateTime.now().minusHours(2)); quizExercise.setDuration(3600); quizExerciseService.endQuiz(quizExercise, ZonedDateTime.now().minusHours(1)); quizExercise.setIsOpenForPractice(true); - quizExercise = request.putWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.OK); + quizExercise = updateQuizExerciseWithFiles(quizExercise, List.of(), HttpStatus.OK); // generate unrated submissions for each student int numberOfParticipants = 10; @@ -741,8 +816,7 @@ void testReevaluateStatistics_Practice() throws Exception { log.debug("QuizPointStatistic before re-evaluate: {}", quizExercise.getQuizPointStatistic()); // reevaluate without changing anything and check if statistics are still correct - QuizExercise quizExerciseWithReevaluatedStatistics = request.putWithResponseBody("/api/quiz-exercises/" + quizExercise.getId() + "/re-evaluate/", quizExercise, - QuizExercise.class, HttpStatus.OK); + QuizExercise quizExerciseWithReevaluatedStatistics = reevalQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.OK); checkStatistics(quizExercise, quizExerciseWithReevaluatedStatistics); log.debug("QuizPointStatistic after re-evaluate (without changes): {}", quizExerciseWithReevaluatedStatistics.getQuizPointStatistic()); @@ -751,8 +825,7 @@ void testReevaluateStatistics_Practice() throws Exception { MultipleChoiceQuestion mc = (MultipleChoiceQuestion) quizExerciseWithReevaluatedStatistics.getQuizQuestions().get(0); mc.getAnswerOptions().remove(1); - quizExerciseWithReevaluatedStatistics = request.putWithResponseBody("/api/quiz-exercises/" + quizExerciseWithReevaluatedStatistics.getId() + "/re-evaluate/", - quizExerciseWithReevaluatedStatistics, QuizExercise.class, HttpStatus.OK); + quizExerciseWithReevaluatedStatistics = reevalQuizExerciseWithFiles(quizExerciseWithReevaluatedStatistics, quizExercise.getId(), List.of(), HttpStatus.OK); // one student should get a higher score assertThat(quizExerciseWithReevaluatedStatistics.getQuizPointStatistic().getPointCounters()).hasSameSizeAs(quizExercise.getQuizPointStatistic().getPointCounters()); @@ -764,8 +837,7 @@ void testReevaluateStatistics_Practice() throws Exception { // set a question invalid and reevaluate quizExerciseWithReevaluatedStatistics.getQuizQuestions().get(2).setInvalid(true); - quizExerciseWithReevaluatedStatistics = request.putWithResponseBody("/api/quiz-exercises/" + quizExerciseWithReevaluatedStatistics.getId() + "/re-evaluate/", - quizExerciseWithReevaluatedStatistics, QuizExercise.class, HttpStatus.OK); + quizExerciseWithReevaluatedStatistics = reevalQuizExerciseWithFiles(quizExerciseWithReevaluatedStatistics, quizExercise.getId(), List.of(), HttpStatus.OK); // several students should get a higher score assertThat(quizExerciseWithReevaluatedStatistics.getQuizPointStatistic().getPointCounters()).hasSameSizeAs(quizExercise.getQuizPointStatistic().getPointCounters()); @@ -776,8 +848,7 @@ void testReevaluateStatistics_Practice() throws Exception { // delete a question and reevaluate quizExerciseWithReevaluatedStatistics.getQuizQuestions().remove(1); - quizExerciseWithReevaluatedStatistics = request.putWithResponseBody("/api/quiz-exercises/" + quizExerciseWithReevaluatedStatistics.getId() + "/re-evaluate/", - quizExerciseWithReevaluatedStatistics, QuizExercise.class, HttpStatus.OK); + quizExerciseWithReevaluatedStatistics = reevalQuizExerciseWithFiles(quizExerciseWithReevaluatedStatistics, quizExercise.getId(), List.of(), HttpStatus.OK); // max score should be less log.debug("QuizPointStatistic after 3rd re-evaluate: {}", quizExerciseWithReevaluatedStatistics.getQuizPointStatistic()); @@ -814,8 +885,7 @@ void testReEvaluateQuizQuestionWithMoreSolutions() throws Exception { quizExercise.getQuizQuestions().add(shortAnswerQuestion); } // PUT Request with the newly modified quizExercise - QuizExercise updatedQuizExercise = request.putWithResponseBody("/api/quiz-exercises/" + quizExercise.getId() + "/re-evaluate", quizExercise, QuizExercise.class, - HttpStatus.OK); + QuizExercise updatedQuizExercise = reevalQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.OK); // Check that the updatedQuizExercise is equal to the modified quizExercise with special focus on the newly added solution and mapping assertThat(updatedQuizExercise).isEqualTo(quizExercise); ShortAnswerQuestion receivedShortAnswerQuestion = (ShortAnswerQuestion) updatedQuizExercise.getQuizQuestions().get(2); @@ -937,7 +1007,7 @@ void testCannotPerformJoinTwice(QuizMode quizMode) throws Exception { void testCreateQuizExerciseAsNonEditorForbidden() throws Exception { QuizExercise quizExercise = quizExerciseUtilService.createQuiz(ZonedDateTime.now().plusDays(5), null, QuizMode.SYNCHRONIZED); - request.postWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.FORBIDDEN); + createQuizExerciseWithFiles(quizExercise, HttpStatus.FORBIDDEN, true); assertThat(quizExercise.getCourseViaExerciseGroupOrCourseMember().getExercises()).isEmpty(); } @@ -1017,7 +1087,7 @@ void testGetQuizExerciseForNonTutorNotInCourseForbidden() throws Exception { void testReEvaluateQuizAsNonInstructorForbidden() throws Exception { QuizExercise quizExercise = quizExerciseUtilService.createAndSaveQuiz(ZonedDateTime.now().minusDays(2), ZonedDateTime.now().plusDays(2), QuizMode.SYNCHRONIZED); - request.put("/api/quiz-exercises/" + quizExercise.getId() + "/re-evaluate", quizExercise, HttpStatus.FORBIDDEN); + reevalQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.FORBIDDEN); } /** @@ -1028,7 +1098,7 @@ void testReEvaluateQuizAsNonInstructorForbidden() throws Exception { void testUnfinishedExamReEvaluateBadRequest() throws Exception { QuizExercise quizExercise = quizExerciseUtilService.createAndSaveExamQuiz(ZonedDateTime.now().minusDays(2), ZonedDateTime.now().plusDays(2)); - request.put("/api/quiz-exercises/" + quizExercise.getId() + "/re-evaluate", quizExercise, HttpStatus.BAD_REQUEST); + reevalQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.BAD_REQUEST); } /** @@ -1040,7 +1110,7 @@ void testUpdateQuizExerciseAsNonEditorForbidden() throws Exception { QuizExercise quizExercise = quizExerciseUtilService.createAndSaveQuiz(ZonedDateTime.now().minusDays(2), ZonedDateTime.now().minusHours(1), QuizMode.SYNCHRONIZED); quizExercise.setTitle("New Title"); - request.put("/api/quiz-exercises", quizExercise, HttpStatus.FORBIDDEN); + updateQuizExerciseWithFiles(quizExercise, List.of(), HttpStatus.FORBIDDEN); } /** @@ -1055,7 +1125,7 @@ void testUpdateQuizExerciseInvalidBadRequest() throws Exception { // make the exercise invalid quizExercise.setTitle(null); assertThat(quizExercise.isValid()).isFalse(); - request.put("/api/quiz-exercises", quizExercise, HttpStatus.BAD_REQUEST); + updateQuizExerciseWithFiles(quizExercise, List.of(), HttpStatus.BAD_REQUEST); } /** @@ -1070,7 +1140,7 @@ void testUpdateQuizExerciseWithNotificationText() throws Exception { var params = new LinkedMultiValueMap(); params.add("notificationText", "NotificationTextTEST!"); - request.putWithResponseBodyAndParams("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.OK, params); + updateQuizExerciseWithFiles(quizExercise, List.of(), HttpStatus.OK, params); // TODO check if notifications arrived correctly } @@ -1081,8 +1151,12 @@ void testUpdateQuizExerciseWithNotificationText() throws Exception { @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void importQuizExerciseToSameCourse() throws Exception { ZonedDateTime now = ZonedDateTime.now(); - QuizExercise quizExercise = quizExerciseUtilService.createAndSaveQuiz(now.plusHours(2), null, QuizMode.SYNCHRONIZED); + QuizExercise quizExercise = quizExerciseUtilService.createQuiz(now.plusHours(2), null, QuizMode.SYNCHRONIZED); courseUtilService.enableMessagingForCourse(quizExercise.getCourseViaExerciseGroupOrCourseMember()); + quizExerciseService.handleDndQuizFileCreation(quizExercise, + List.of(new MockMultipartFile("files", "dragItemImage2.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()), + new MockMultipartFile("files", "dragItemImage4.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()))); + quizExerciseService.save(quizExercise); QuizExercise changedQuiz = quizExerciseRepository.findOneWithQuestionsAndStatistics(quizExercise.getId()); assertThat(changedQuiz).isNotNull(); @@ -1090,7 +1164,7 @@ void importQuizExerciseToSameCourse() throws Exception { changedQuiz.setReleaseDate(now); changedQuiz.setChannelName("testchannel-quiz"); - QuizExercise importedExercise = request.postWithResponseBody("/api/quiz-exercises/import/" + changedQuiz.getId(), changedQuiz, QuizExercise.class, HttpStatus.CREATED); + QuizExercise importedExercise = importQuizExerciseWithFiles(changedQuiz, changedQuiz.getId(), List.of(), HttpStatus.CREATED); assertThat(importedExercise.getId()).as("Imported exercise has different id").isNotEqualTo(quizExercise.getId()); assertThat(importedExercise.getTitle()).as("Imported exercise has updated title").isEqualTo("New title"); @@ -1111,12 +1185,17 @@ void importQuizExerciseToSameCourse() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void importQuizExerciseFromCourseToCourse() throws Exception { - QuizExercise quizExercise = quizExerciseUtilService.createAndSaveQuiz(ZonedDateTime.now().plusHours(2), null, QuizMode.SYNCHRONIZED); + ZonedDateTime now = ZonedDateTime.now(); + QuizExercise quizExercise = quizExerciseUtilService.createQuiz(now.plusHours(2), null, QuizMode.SYNCHRONIZED); + quizExerciseService.handleDndQuizFileCreation(quizExercise, + List.of(new MockMultipartFile("files", "dragItemImage2.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()), + new MockMultipartFile("files", "dragItemImage4.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()))); + quizExerciseService.save(quizExercise); courseUtilService.enableMessagingForCourse(quizExercise.getCourseViaExerciseGroupOrCourseMember()); quizExercise.setChannelName("testchannel-quiz" + UUID.randomUUID().toString().substring(0, 8)); - QuizExercise importedExercise = request.postWithResponseBody("/api/quiz-exercises/import/" + quizExercise.getId(), quizExercise, QuizExercise.class, HttpStatus.CREATED); + QuizExercise importedExercise = importQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.CREATED); assertThat(importedExercise.getCourseViaExerciseGroupOrCourseMember()).as("Quiz was imported for different course") .isEqualTo(quizExercise.getCourseViaExerciseGroupOrCourseMember()); Channel channelDB = channelRepository.findChannelByExerciseId(importedExercise.getId()); @@ -1130,12 +1209,15 @@ void importQuizExerciseFromCourseToCourse() throws Exception { @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void importQuizExerciseFromCourseToExam() throws Exception { ExerciseGroup exerciseGroup = examUtilService.createAndSaveActiveExerciseGroup(true); - QuizExercise quizExercise = quizExerciseUtilService.createAndSaveQuiz(ZonedDateTime.now().plusHours(2), null, QuizMode.SYNCHRONIZED); - + QuizExercise quizExercise = quizExerciseUtilService.createQuiz(ZonedDateTime.now().plusHours(2), null, QuizMode.SYNCHRONIZED); + quizExerciseService.handleDndQuizFileCreation(quizExercise, + List.of(new MockMultipartFile("files", "dragItemImage2.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()), + new MockMultipartFile("files", "dragItemImage4.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()))); + quizExerciseService.save(quizExercise); quizExerciseUtilService.emptyOutQuizExercise(quizExercise); quizExercise.setExerciseGroup(exerciseGroup); - QuizExercise importedExercise = request.postWithResponseBody("/api/quiz-exercises/import/" + quizExercise.getId(), quizExercise, QuizExercise.class, HttpStatus.CREATED); + QuizExercise importedExercise = importQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.CREATED); assertThat(importedExercise.getExerciseGroup()).as("Quiz was imported for different exercise group").isEqualTo(exerciseGroup); Channel channelDB = channelRepository.findChannelByExerciseId(importedExercise.getId()); assertThat(channelDB).isNull(); @@ -1153,7 +1235,7 @@ void importQuizExerciseFromCourseToExam_forbidden() throws Exception { quizExerciseUtilService.emptyOutQuizExercise(quizExercise); quizExercise.setExerciseGroup(exerciseGroup); - request.postWithResponseBody("/api/quiz-exercises/import/" + quizExercise.getId(), quizExercise, QuizExercise.class, HttpStatus.FORBIDDEN); + importQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.FORBIDDEN); } /** @@ -1165,12 +1247,16 @@ void importQuizExerciseFromExamToCourse() throws Exception { QuizExercise quizExercise = quizExerciseUtilService.createAndSaveExamQuiz(ZonedDateTime.now(), ZonedDateTime.now().plusDays(1)); quizExercise.setExerciseGroup(null); - Course course1 = courseUtilService.addEmptyCourse(); - quizExercise.setCourse(course1); + Course course = courseUtilService.addEmptyCourse(); + quizExerciseService.handleDndQuizFileCreation(quizExercise, + List.of(new MockMultipartFile("files", "dragItemImage2.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()), + new MockMultipartFile("files", "dragItemImage4.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()))); + quizExerciseService.save(quizExercise); + quizExercise.setCourse(course); quizExercise.setChannelName("testchannel-quiz"); - QuizExercise importedExercise = request.postWithResponseBody("/api/quiz-exercises/import/" + quizExercise.getId(), quizExercise, QuizExercise.class, HttpStatus.CREATED); - assertThat(importedExercise.getCourseViaExerciseGroupOrCourseMember()).isEqualTo(course1); + QuizExercise importedExercise = importQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.CREATED); + assertThat(importedExercise.getCourseViaExerciseGroupOrCourseMember()).isEqualTo(course); } /** @@ -1185,7 +1271,7 @@ void importQuizExerciseFromExamToCourse_forbidden() throws Exception { Course course1 = courseUtilService.addEmptyCourse(); quizExercise.setCourse(course1); - request.postWithResponseBody("/api/quiz-exercises/import/" + quizExercise.getId(), quizExercise, QuizExercise.class, HttpStatus.FORBIDDEN); + importQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.FORBIDDEN); } /** @@ -1195,10 +1281,13 @@ void importQuizExerciseFromExamToCourse_forbidden() throws Exception { @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void importQuizExerciseFromExamToExam() throws Exception { ExerciseGroup exerciseGroup = examUtilService.createAndSaveActiveExerciseGroup(true); - QuizExercise quizExercise = quizExerciseUtilService.createAndSaveExamQuiz(ZonedDateTime.now().plusDays(1), ZonedDateTime.now().plusDays(2)); - quizExercise.setExerciseGroup(exerciseGroup); + QuizExercise quizExercise = QuizExerciseFactory.createQuizForExam(exerciseGroup); + quizExerciseService.handleDndQuizFileCreation(quizExercise, + List.of(new MockMultipartFile("files", "dragItemImage2.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()), + new MockMultipartFile("files", "dragItemImage4.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()))); + quizExerciseService.save(quizExercise); - QuizExercise importedExercise = request.postWithResponseBody("/api/quiz-exercises/import/" + quizExercise.getId(), quizExercise, QuizExercise.class, HttpStatus.CREATED); + QuizExercise importedExercise = importQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.CREATED); assertThat(importedExercise.getExerciseGroup()).as("Quiz was imported for different exercise group").isEqualTo(exerciseGroup); } @@ -1211,7 +1300,7 @@ void importQuizExerciseFromCourseToCourse_badRequest() throws Exception { QuizExercise quizExercise = quizExerciseUtilService.createAndSaveQuiz(ZonedDateTime.now().plusHours(2), null, QuizMode.SYNCHRONIZED); quizExercise.setCourse(null); - request.postWithResponseBody("/api/quiz-exercises/import/" + quizExercise.getId(), quizExercise, QuizExercise.class, HttpStatus.BAD_REQUEST); + importQuizExerciseWithFiles(quizExercise, quizExercise.getId(), List.of(), HttpStatus.BAD_REQUEST); } /** @@ -1220,17 +1309,21 @@ void importQuizExerciseFromCourseToCourse_badRequest() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testImportQuizExercise_team_modeChange() throws Exception { - QuizExercise quizExercise = quizExerciseUtilService.createAndSaveQuiz(ZonedDateTime.now().plusHours(2), null, QuizMode.SYNCHRONIZED); - Course course = courseUtilService.addEmptyCourse(); + QuizExercise quizExercise = quizExerciseUtilService.createQuiz(ZonedDateTime.now().plusHours(2), null, QuizMode.SYNCHRONIZED); + quizExerciseService.handleDndQuizFileCreation(quizExercise, + List.of(new MockMultipartFile("files", "dragItemImage2.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()), + new MockMultipartFile("files", "dragItemImage4.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()))); + quizExerciseService.save(quizExercise); QuizExercise changedQuiz = quizExerciseRepository.findOneWithQuestionsAndStatistics(quizExercise.getId()); assertThat(changedQuiz).isNotNull(); + Course course = courseUtilService.addEmptyCourse(); changedQuiz.setCourse(course); changedQuiz.setChannelName("testchannel-quiz"); quizExerciseUtilService.setupTeamQuizExercise(changedQuiz, 1, 10); - changedQuiz = request.postWithResponseBody("/api/quiz-exercises/import/" + quizExercise.getId(), changedQuiz, QuizExercise.class, HttpStatus.CREATED); + changedQuiz = importQuizExerciseWithFiles(changedQuiz, quizExercise.getId(), List.of(), HttpStatus.CREATED); assertThat(changedQuiz.getCourseViaExerciseGroupOrCourseMember().getId()).isEqualTo(course.getId()); assertThat(changedQuiz.getMode()).isEqualTo(ExerciseMode.TEAM); @@ -1251,17 +1344,32 @@ void testImportQuizExercise_team_modeChange() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testImportQuizExercise_individual_modeChange() throws Exception { - QuizExercise quizExercise = quizExerciseUtilService.createAndSaveTeamQuiz(ZonedDateTime.now().plusHours(2), null, QuizMode.SYNCHRONIZED, 2, 5); - Course course = courseUtilService.addEmptyCourse(); + QuizExercise quizExercise = quizExerciseUtilService.createQuiz(ZonedDateTime.now().plusHours(2), null, QuizMode.SYNCHRONIZED); + quizExercise.setMode(ExerciseMode.TEAM); + var teamAssignmentConfig = new TeamAssignmentConfig(); + teamAssignmentConfig.setExercise(quizExercise); + teamAssignmentConfig.setMinTeamSize(1); + teamAssignmentConfig.setMaxTeamSize(10); + quizExercise.setTeamAssignmentConfig(teamAssignmentConfig); + + quizExerciseService.handleDndQuizFileCreation(quizExercise, + List.of(new MockMultipartFile("files", "dragItemImage2.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()), + new MockMultipartFile("files", "dragItemImage4.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()))); + + quizExercise = quizExerciseService.save(quizExercise); + var team = new Team(); + team.setShortName(TEST_PREFIX + "testImportQuizExercise_individual_modeChange"); + teamRepository.save(quizExercise, team); QuizExercise changedQuiz = quizExerciseRepository.findOneWithQuestionsAndStatistics(quizExercise.getId()); assertThat(changedQuiz).isNotNull(); changedQuiz.setMode(ExerciseMode.INDIVIDUAL); + Course course = courseUtilService.addEmptyCourse(); changedQuiz.setCourse(course); changedQuiz.setChannelName("testchannel-quiz"); - changedQuiz = request.postWithResponseBody("/api/quiz-exercises/import/" + quizExercise.getId(), changedQuiz, QuizExercise.class, HttpStatus.CREATED); + changedQuiz = importQuizExerciseWithFiles(changedQuiz, quizExercise.getId(), List.of(), HttpStatus.CREATED); assertThat(changedQuiz.getCourseViaExerciseGroupOrCourseMember().getId()).isEqualTo(course.getId()); assertThat(changedQuiz.getMode()).isEqualTo(ExerciseMode.INDIVIDUAL); @@ -1279,14 +1387,18 @@ void testImportQuizExercise_individual_modeChange() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void testImportQuizExerciseChangeQuizMode() throws Exception { - QuizExercise quizExercise = quizExerciseUtilService.createAndSaveQuiz(ZonedDateTime.now().plusHours(2), null, QuizMode.SYNCHRONIZED); + QuizExercise quizExercise = quizExerciseUtilService.createQuiz(ZonedDateTime.now().plusHours(2), null, QuizMode.SYNCHRONIZED); + quizExerciseService.handleDndQuizFileCreation(quizExercise, + List.of(new MockMultipartFile("files", "dragItemImage2.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()), + new MockMultipartFile("files", "dragItemImage4.png", MediaType.IMAGE_PNG_VALUE, "dragItemImage".getBytes()))); + quizExerciseService.save(quizExercise); QuizExercise changedQuiz = quizExerciseRepository.findOneWithQuestionsAndStatistics(quizExercise.getId()); assertThat(changedQuiz).isNotNull(); changedQuiz.setQuizMode(QuizMode.INDIVIDUAL); changedQuiz.setChannelName("testchannel-quiz"); - QuizExercise importedExercise = request.postWithResponseBody("/api/quiz-exercises/import/" + changedQuiz.getId(), changedQuiz, QuizExercise.class, HttpStatus.CREATED); + QuizExercise importedExercise = importQuizExerciseWithFiles(changedQuiz, quizExercise.getId(), List.of(), HttpStatus.CREATED); assertThat(importedExercise.getId()).as("Imported exercise has different id").isNotEqualTo(quizExercise.getId()); assertThat(importedExercise.getQuizMode()).as("Imported exercise has different quiz mode").isEqualTo(QuizMode.INDIVIDUAL); @@ -1329,8 +1441,7 @@ void testMultipleChoiceQuizExplanationLength_Valid() throws Exception { question.setExplanation("0".repeat(validityThreshold)); quizExercise.setChannelName("testchannel-quiz"); - QuizExercise response = request.postWithResponseBody("/api/quiz-exercises/", quizExercise, QuizExercise.class, HttpStatus.CREATED); - assertThat(response).isNotNull(); + createQuizExerciseWithFiles(quizExercise, HttpStatus.CREATED, true); } /** @@ -1345,7 +1456,7 @@ void testMultipleChoiceQuizExplanationLength_Invalid() throws Exception { MultipleChoiceQuestion question = (MultipleChoiceQuestion) quizExercise.getQuizQuestions().get(0); question.setExplanation("0".repeat(validityThreshold + 1)); - request.postWithResponseBody("/api/quiz-exercises/", quizExercise, QuizExercise.class, HttpStatus.INTERNAL_SERVER_ERROR); + createQuizExerciseWithFiles(quizExercise, HttpStatus.INTERNAL_SERVER_ERROR, true); } /** @@ -1361,8 +1472,7 @@ void testMultipleChoiceQuizOptionExplanationLength_Valid() throws Exception { question.getAnswerOptions().get(0).setExplanation("0".repeat(validityThreshold)); quizExercise.setChannelName("testchannel-quiz"); - QuizExercise response = request.postWithResponseBody("/api/quiz-exercises/", quizExercise, QuizExercise.class, HttpStatus.CREATED); - assertThat(response).isNotNull(); + createQuizExerciseWithFiles(quizExercise, HttpStatus.CREATED, true); } /** @@ -1377,7 +1487,7 @@ void testMultipleChoiceQuizOptionExplanationLength_Invalid() throws Exception { MultipleChoiceQuestion question = (MultipleChoiceQuestion) quizExercise.getQuizQuestions().get(0); question.getAnswerOptions().get(0).setExplanation("0".repeat(validityThreshold + 1)); - request.postWithResponseBody("/api/quiz-exercises/", quizExercise, QuizExercise.class, HttpStatus.INTERNAL_SERVER_ERROR); + createQuizExerciseWithFiles(quizExercise, HttpStatus.INTERNAL_SERVER_ERROR, true); } @Test @@ -1402,6 +1512,74 @@ void testReset() throws Exception { } } + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void createQuizExercise_dragAndDrop_withoutBackgroundFile() throws Exception { + QuizExercise quizExercise = quizExerciseUtilService.createQuiz(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); + quizExercise.setDuration(3600); + createQuizExerciseWithFiles(quizExercise, HttpStatus.CREATED, false); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void createQuizExercise_withoutDragAndDrop() throws Exception { + QuizExercise quizExercise = quizExerciseUtilService.createQuiz(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); + quizExercise.setQuizQuestions(quizExercise.getQuizQuestions().stream().filter(question -> !(question instanceof DragAndDropQuestion)).toList()); + quizExercise.setDuration(3600); + createQuizExerciseWithFiles(quizExercise, HttpStatus.CREATED, false); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void updateQuizExercise_withoutDragAndDrop() throws Exception { + QuizExercise quizExercise = quizExerciseUtilService.createQuiz(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); + quizExercise.setQuizQuestions(quizExercise.getQuizQuestions().stream().filter(question -> !(question instanceof DragAndDropQuestion)).toList()); + quizExercise.setDuration(3600); + quizExercise = createQuizExerciseWithFiles(quizExercise, HttpStatus.CREATED, false); + updateQuizExerciseWithFiles(quizExercise, null, HttpStatus.OK); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void updateQuizExercise_dragAndDrop_withoutFileArrayProvided() throws Exception { + QuizExercise quizExercise = createQuizOnServer(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); + updateQuizExerciseWithFiles(quizExercise, null, HttpStatus.OK); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void updateQuizExercise_dragAndDrop_withFileChanges() throws Exception { + QuizExercise quizExercise = createQuizOnServer(ZonedDateTime.now().plusHours(5), null, QuizMode.SYNCHRONIZED); + String newBackgroundFilePath = "newBackgroundFile.png"; + String newPictureFilePath = "newPictureFile.jpg"; + + List dragAndDropQuestions = quizExercise.getQuizQuestions().stream().filter(q -> q instanceof DragAndDropQuestion).map(q -> (DragAndDropQuestion) q) + .toList(); + DragAndDropQuestion question = dragAndDropQuestions.get(0); + question.setBackgroundFilePath(newBackgroundFilePath); + DragItem item = question.getDragItems().get(1); + item.setPictureFilePath(newPictureFilePath); + + updateQuizExerciseWithFiles(quizExercise, List.of(newBackgroundFilePath, newPictureFilePath), HttpStatus.OK); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testFilterForCourseDashboard_QuizSubmissionButNoParticipation() { + Course course = quizExerciseUtilService.addCourseWithOneQuizExercise(); + QuizExercise quizExercise = (QuizExercise) course.getExercises().stream().findFirst().get(); + + QuizSubmission quizSubmission = QuizExerciseFactory.generateSubmissionForThreeQuestions(quizExercise, 1, true, ZonedDateTime.now()); + participationUtilService.addSubmission(quizExercise, quizSubmission, TEST_PREFIX + "student1"); + + quizScheduleService.updateSubmission(quizExercise.getId(), TEST_PREFIX + "student1", quizSubmission); + + exerciseService.filterForCourseDashboard(quizExercise, List.of(), TEST_PREFIX + "student1", true); + + assertThat(quizExercise.getStudentParticipations()).hasSize(1); + assertThat(quizExercise.getStudentParticipations().stream().findFirst().get().getInitializationState()).isEqualTo(InitializationState.INITIALIZED); + } + private QuizExercise createQuizOnServer(ZonedDateTime releaseDate, ZonedDateTime dueDate, QuizMode quizMode) throws Exception { return createQuizOnServer(releaseDate, dueDate, quizMode, "exercise-new-quiz"); } @@ -1412,7 +1590,7 @@ private QuizExercise createQuizOnServer(ZonedDateTime releaseDate, ZonedDateTime quizExercise.setChannelName(channelName); courseUtilService.enableMessagingForCourse(quizExercise.getCourseViaExerciseGroupOrCourseMember()); - QuizExercise quizExerciseServer = request.postWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.CREATED); + QuizExercise quizExerciseServer = createQuizExerciseWithFiles(quizExercise, HttpStatus.CREATED, true); QuizExercise quizExerciseDatabase = quizExerciseRepository.findOneWithQuestionsAndStatistics(quizExerciseServer.getId()); assertThat(quizExerciseServer).isNotNull(); assertThat(quizExerciseDatabase).isNotNull(); @@ -1447,7 +1625,7 @@ private QuizExercise createQuizOnServerForExam() throws Exception { QuizExercise quizExercise = QuizExerciseFactory.createQuizForExam(exerciseGroup); quizExercise.setDuration(3600); - QuizExercise quizExerciseServer = request.postWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.CREATED); + QuizExercise quizExerciseServer = createQuizExerciseWithFiles(quizExercise, HttpStatus.CREATED, true); QuizExercise quizExerciseDatabase = quizExerciseRepository.findOneWithQuestionsAndStatistics(quizExerciseServer.getId()); assertThat(quizExerciseServer).isNotNull(); assertThat(quizExerciseDatabase).isNotNull(); @@ -1504,8 +1682,8 @@ private void createdQuizAssert(QuizExercise quizExercise) { assertThat(answerOptions.get(1).isIsCorrect()).as("Is correct for answer option is correct").isFalse(); } else if (question instanceof DragAndDropQuestion dragAndDropQuestion) { - assertThat(dragAndDropQuestion.getDropLocations()).as("Drag and drop question drop locations were saved").hasSize(3); - assertThat(dragAndDropQuestion.getDragItems()).as("Drag and drop question drag items were saved").hasSize(3); + assertThat(dragAndDropQuestion.getDropLocations()).as("Drag and drop question drop locations were saved").hasSize(4); + assertThat(dragAndDropQuestion.getDragItems()).as("Drag and drop question drag items were saved").hasSize(4); assertThat(dragAndDropQuestion.getTitle()).as("Drag and drop question title is correct").isEqualTo("DnD"); assertThat(dragAndDropQuestion.getText()).as("Drag and drop question text is correct").isEqualTo("Q2"); assertThat(dragAndDropQuestion.getPoints()).as("Drag and drop question score is correct").isEqualTo(3); @@ -1521,10 +1699,26 @@ else if (question instanceof DragAndDropQuestion dragAndDropQuestion) { assertThat(dropLocations.get(1).getWidth()).as("Width for drop location is correct").isEqualTo(10); assertThat(dropLocations.get(1).getHeight()).as("Height for drop location is correct").isEqualTo(10); assertThat(dropLocations.get(1).getQuestion()).isNotNull(); + assertThat(dropLocations.get(2).getPosX()).as("Pos X for drop location is correct").isEqualTo(30); + assertThat(dropLocations.get(2).getPosY()).as("Pos Y for drop location is correct").isEqualTo(30); + assertThat(dropLocations.get(2).getWidth()).as("Width for drop location is correct").isEqualTo(10); + assertThat(dropLocations.get(2).getHeight()).as("Height for drop location is correct").isEqualTo(10); + assertThat(dropLocations.get(2).getQuestion()).isNotNull(); + assertThat(dropLocations.get(3).getPosX()).as("Pos X for drop location is correct").isEqualTo(40); + assertThat(dropLocations.get(3).getPosY()).as("Pos Y for drop location is correct").isEqualTo(40); + assertThat(dropLocations.get(3).getWidth()).as("Width for drop location is correct").isEqualTo(10); + assertThat(dropLocations.get(3).getHeight()).as("Height for drop location is correct").isEqualTo(10); + assertThat(dropLocations.get(3).getQuestion()).isNotNull(); List dragItems = dragAndDropQuestion.getDragItems(); assertThat(dragItems.get(0).getText()).as("Text for drag item is correct").isEqualTo("D1"); - assertThat(dragItems.get(1).getText()).as("Text for drag item is correct").isEqualTo("D2"); + assertThat(dragItems.get(0).getPictureFilePath()).as("Picture file path for drag item is correct").isNull(); + assertThat(dragItems.get(1).getText()).as("Text for drag item is correct").isNull(); + assertThat(dragItems.get(1).getPictureFilePath()).as("Picture file path for drag item is correct").isNotEmpty(); + assertThat(dragItems.get(2).getText()).as("Text for drag item is correct").isEqualTo("D3"); + assertThat(dragItems.get(2).getPictureFilePath()).as("Picture file path for drag item is correct").isNull(); + assertThat(dragItems.get(3).getText()).as("Text for drag item is correct").isNull(); + assertThat(dragItems.get(3).getPictureFilePath()).as("Picture file path for drag item is correct").isNotEmpty(); } else if (question instanceof ShortAnswerQuestion shortAnswerQuestion) { assertThat(shortAnswerQuestion.getSpots()).as("Short answer question spots were saved").hasSize(2); @@ -1549,7 +1743,7 @@ else if (question instanceof ShortAnswerQuestion shortAnswerQuestion) { private void updateQuizAndAssert(QuizExercise quizExercise) throws Exception { updateMultipleChoice(quizExercise); - quizExercise = request.putWithResponseBody("/api/quiz-exercises", quizExercise, QuizExercise.class, HttpStatus.OK); + quizExercise = updateQuizExerciseWithFiles(quizExercise, List.of(), HttpStatus.OK); // Quiz type specific assertions for (QuizQuestion question : quizExercise.getQuizQuestions()) { @@ -1574,8 +1768,8 @@ private void updateQuizAndAssert(QuizExercise quizExercise) throws Exception { assertThat(answerOptions.get(2).isIsCorrect()).as("Is correct for answer option is correct").isTrue(); } else if (question instanceof DragAndDropQuestion dragAndDropQuestion) { - assertThat(dragAndDropQuestion.getDropLocations()).as("Drag and drop question drop locations were saved").hasSize(2); - assertThat(dragAndDropQuestion.getDragItems()).as("Drag and drop question drag items were saved").hasSize(2); + assertThat(dragAndDropQuestion.getDropLocations()).as("Drag and drop question drop locations were saved").hasSize(3); + assertThat(dragAndDropQuestion.getDragItems()).as("Drag and drop question drag items were saved").hasSize(3); assertThat(dragAndDropQuestion.getTitle()).as("Drag and drop question title is correct").isEqualTo("DnD"); assertThat(dragAndDropQuestion.getText()).as("Drag and drop question text is correct").isEqualTo("Q2"); assertThat(dragAndDropQuestion.getPoints()).as("Drag and drop question score is correct").isEqualTo(3); @@ -1585,9 +1779,22 @@ else if (question instanceof DragAndDropQuestion dragAndDropQuestion) { assertThat(dropLocations.get(0).getPosY()).as("Pos Y for drop location is correct").isEqualTo(20); assertThat(dropLocations.get(0).getWidth()).as("Width for drop location is correct").isEqualTo(10); assertThat(dropLocations.get(0).getHeight()).as("Height for drop location is correct").isEqualTo(10); + assertThat(dropLocations.get(1).getPosX()).as("Pos X for drop location is correct").isEqualTo(30); + assertThat(dropLocations.get(1).getPosY()).as("Pos Y for drop location is correct").isEqualTo(30); + assertThat(dropLocations.get(1).getWidth()).as("Width for drop location is correct").isEqualTo(10); + assertThat(dropLocations.get(1).getHeight()).as("Height for drop location is correct").isEqualTo(10); + assertThat(dropLocations.get(2).getPosX()).as("Pos X for drop location is correct").isEqualTo(40); + assertThat(dropLocations.get(2).getPosY()).as("Pos Y for drop location is correct").isEqualTo(40); + assertThat(dropLocations.get(2).getWidth()).as("Width for drop location is correct").isEqualTo(10); + assertThat(dropLocations.get(2).getHeight()).as("Height for drop location is correct").isEqualTo(10); List dragItems = dragAndDropQuestion.getDragItems(); - assertThat(dragItems.get(0).getText()).as("Text for drag item is correct").isEqualTo("D2"); + assertThat(dragItems.get(0).getText()).as("Text for drag item is correct").isNull(); + assertThat(dragItems.get(0).getPictureFilePath()).as("Picture file path for drag item is correct").isNotEmpty(); + assertThat(dragItems.get(1).getText()).as("Text for drag item is correct").isEqualTo("D3"); + assertThat(dragItems.get(1).getPictureFilePath()).as("Picture file path for drag item is correct").isNull(); + assertThat(dragItems.get(2).getText()).as("Text for drag item is correct").isNull(); + assertThat(dragItems.get(2).getPictureFilePath()).as("Picture file path for drag item is correct").isNotEmpty(); } else if (question instanceof ShortAnswerQuestion shortAnswerQuestion) { assertThat(shortAnswerQuestion.getSpots()).as("Short answer question spots were saved").hasSize(1); diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizSubmissionIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizSubmissionIntegrationTest.java index c5290695b2b2..9889e79125b3 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizSubmissionIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/quizexercise/QuizSubmissionIntegrationTest.java @@ -294,10 +294,7 @@ void testQuizSubmit_partial_points() { for (var pointCounter : quizPointStatistic.getPointCounters()) { assertThat(pointCounter.getUnRatedCounter()).as("Unrated counter is always 0").isZero(); if (pointCounter.getPoints() == 0.0) { - assertThat(pointCounter.getRatedCounter()).as("Bucket 0.0 contains 0 rated submission -> 0.33 points").isEqualTo(1); - } - else if (pointCounter.getPoints() == 1.0) { - assertThat(pointCounter.getRatedCounter()).as("Bucket 1.0 contains 0 rated submission -> 0.83 points").isEqualTo(1); + assertThat(pointCounter.getRatedCounter()).as("Bucket 0.0 contains 0 rated submission -> 0.33 points").isEqualTo(2); } else if (pointCounter.getPoints() == 6.0) { assertThat(pointCounter.getRatedCounter()).as("Bucket 6.0 contains 1 rated submission -> 6 points").isEqualTo(1); @@ -698,7 +695,7 @@ void testQuizScoringType(ScoringType scoringType) { double expectedScore = switch (scoringType) { case ALL_OR_NOTHING, PROPORTIONAL_WITH_PENALTY -> 0; - case PROPORTIONAL_WITHOUT_PENALTY -> 44.4; + case PROPORTIONAL_WITHOUT_PENALTY -> 41.7; }; assertThat(result.getScore()).isEqualTo(expectedScore); } diff --git a/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java b/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java index bc47080491ca..df4314c175f0 100644 --- a/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java @@ -105,11 +105,11 @@ public R postWithMultipartFiles(String path, T paramValue, String paramNa } builder.file(json); MvcResult res = mvc.perform(builder).andExpect(status().is(expectedStatus.value())).andReturn(); + restoreSecurityContext(); if (!expectedStatus.is2xxSuccessful()) { assertThat(res.getResponse().containsHeader("location")).as("no location header on failed request").isFalse(); return null; } - restoreSecurityContext(); return mapper.readValue(res.getResponse().getContentAsString(), responseType); } @@ -715,7 +715,7 @@ public void delete(String path, HttpStatus expectedStatus, T body) throws Ex * The Security Context gets cleared by {@link org.springframework.security.web.context.SecurityContextPersistenceFilter} after a REST call. * To prevent issues with further queries and rest calls in a test we restore the security context from the test security context holder */ - private void restoreSecurityContext() { + public void restoreSecurityContext() { SecurityContextHolder.setContext(TestSecurityContextHolder.getContext()); } diff --git a/src/test/javascript/spec/component/apollon-diagrams/exercise-generation/quiz-exercise-generator.spec.ts b/src/test/javascript/spec/component/apollon-diagrams/exercise-generation/quiz-exercise-generator.spec.ts index 7a5e91fc8861..d1b891091084 100644 --- a/src/test/javascript/spec/component/apollon-diagrams/exercise-generation/quiz-exercise-generator.spec.ts +++ b/src/test/javascript/spec/component/apollon-diagrams/exercise-generation/quiz-exercise-generator.spec.ts @@ -78,8 +78,6 @@ describe('QuizExercise Generator', () => { it('generateDragAndDropExercise for Class Diagram', async () => { const svgRenderer = require('app/exercises/quiz/manage/apollon-diagrams/exercise-generation/svg-renderer'); configureServices(); - const examplePath = '/path/to/file'; - jest.spyOn(fileUploaderService, 'uploadFile').mockReturnValue(Promise.resolve({ path: examplePath })); jest.spyOn(quizExerciseService, 'create').mockReturnValue(of({ body: quizExercise } as HttpResponse)); jest.spyOn(svgRenderer, 'convertRenderedSVGToPNG').mockReturnValue(new Blob()); // @ts-ignore diff --git a/src/test/javascript/spec/component/drag-and-drop-question/drag-and-drop-question-edit.component.spec.ts b/src/test/javascript/spec/component/drag-and-drop-question/drag-and-drop-question-edit.component.spec.ts index 5db69313a6ae..2f5a5ee00a31 100644 --- a/src/test/javascript/spec/component/drag-and-drop-question/drag-and-drop-question-edit.component.spec.ts +++ b/src/test/javascript/spec/component/drag-and-drop-question/drag-and-drop-question-edit.component.spec.ts @@ -11,7 +11,7 @@ import { DragAndDropMouseEvent } from 'app/exercises/quiz/manage/drag-and-drop-q import { DragAndDropQuestionEditComponent } from 'app/exercises/quiz/manage/drag-and-drop-question/drag-and-drop-question-edit.component'; import { QuizScoringInfoModalComponent } from 'app/exercises/quiz/manage/quiz-scoring-info-modal/quiz-scoring-info-modal.component'; import { DragAndDropQuestionComponent } from 'app/exercises/quiz/shared/questions/drag-and-drop-question/drag-and-drop-question.component'; -import { FileUploadResponse, FileUploaderService } from 'app/shared/http/file-uploader.service'; +import { FileUploaderService } from 'app/shared/http/file-uploader.service'; import { SecuredImageComponent } from 'app/shared/image/secured-image.component'; import { DomainCommand } from 'app/shared/markdown-editor/domainCommands/domainCommand'; import { ExplanationCommand } from 'app/shared/markdown-editor/domainCommands/explanation.command'; @@ -33,8 +33,11 @@ import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; describe('DragAndDropQuestionEditComponent', () => { let fixture: ComponentFixture; let component: DragAndDropQuestionEditComponent; - let uploadService: FileUploaderService; let modalService: NgbModal; + let createObjectURLStub: jest.SpyInstance; + let questionUpdatedSpy: jest.SpyInstance; + let addFileSpy: jest.SpyInstance; + let removeFileSpy: jest.SpyInstance; const question1 = new DragAndDropQuestion(); question1.id = 1; @@ -42,6 +45,28 @@ describe('DragAndDropQuestionEditComponent', () => { const question2 = new DragAndDropQuestion(); question2.id = 2; question2.backgroundFilePath = ''; + const question3 = new DragAndDropQuestion(); + question3.id = 3; + question3.backgroundFilePath = 'this/is/a/fake/path/1/image.jpg'; + question3.dragItems = [ + { id: 1, pictureFilePath: 'this/is/a/fake/path/2/image.jpg', text: undefined } as DragItem, + { id: 2, pictureFilePath: 'this/is/a/fake/path/3/image.jpg', text: undefined } as DragItem, + { id: 3, pictureFilePath: 'this/is/a/fake/path/4/image.jpg', text: undefined } as DragItem, + ]; + const question4 = new DragAndDropQuestion(); + question4.id = 3; + question4.backgroundFilePath = 'this/is/a/fake/path/1/image.jpg'; + question4.dragItems = [ + { id: 1, pictureFilePath: undefined, text: 'Text1' } as DragItem, + { id: 2, pictureFilePath: undefined, text: 'Text2' } as DragItem, + { id: 3, pictureFilePath: undefined, text: 'Text3' } as DragItem, + ]; + + const createObjectURLBackup = window.URL.createObjectURL; + + beforeAll(() => { + window.URL.createObjectURL = jest.fn(); + }); beforeEach(() => { TestBed.configureTestingModule({ @@ -65,12 +90,17 @@ describe('DragAndDropQuestionEditComponent', () => { }).compileComponents(); fixture = TestBed.createComponent(DragAndDropQuestionEditComponent); component = fixture.componentInstance; - uploadService = TestBed.inject(FileUploaderService); modalService = TestBed.inject(NgbModal); + questionUpdatedSpy = jest.spyOn(component.questionUpdated, 'emit'); + createObjectURLStub = jest.spyOn(window.URL, 'createObjectURL').mockImplementation((file: File) => { + return 'some/client/dependent/path/' + file.name; + }); + addFileSpy = jest.spyOn(component.addNewFile, 'emit'); + removeFileSpy = jest.spyOn(component.removeFile, 'emit'); }); beforeEach(fakeAsync(() => { - component.question = question1; + component.question = clone(question1); component.questionIndex = 1; component.reEvaluationInProgress = false; @@ -82,14 +112,17 @@ describe('DragAndDropQuestionEditComponent', () => { jest.restoreAllMocks(); }); + afterAll(() => { + window.URL.createObjectURL = createObjectURLBackup; + }); + it('should initialize', () => { - expect(component.isQuestionCollapsed).toBeFalse(); - expect(component.isUploadingDragItemFile).toBeFalse(); + expect(component.backupQuestion).toEqual(question1); + expect(component.filePreviewPaths).toEqual(new Map()); expect(component.mouse).toStrictEqual(new DragAndDropMouseEvent()); }); it('should detect changes and update component', () => { - const questionUpdatedSpy = jest.spyOn(component.questionUpdated, 'emit'); triggerChanges(component, { property: 'question', currentValue: question2, previousValue: question1 }); fixture.detectChanges(); @@ -113,43 +146,17 @@ describe('DragAndDropQuestionEditComponent', () => { }); it('should set background file', () => { - const file1 = { name: 'newFile1' }; - const file2 = { name: 'newFile2' }; + const file1 = { name: 'newFile1.jpg' }; + const file2 = { name: 'newFile2.png' }; const event = { target: { files: [file1, file2] } }; - const newPath = 'alwaysGoYourPath'; - const mockReturnValue = Promise.resolve({ path: newPath } as FileUploadResponse); - jest.spyOn(uploadService, 'uploadFile').mockReturnValue(mockReturnValue); component.setBackgroundFile(event); - expect(component.backgroundFile).toEqual(file1); - expect(component.backgroundFileName).toBe(file1.name); - - component.uploadBackground(); + expect(component.question.backgroundFilePath).toEndWith('.jpg'); + expect(addFileSpy).toHaveBeenCalledOnce(); + expect(createObjectURLStub).toHaveBeenCalledExactlyOnceWith(file1); }); - it('should upload file', fakeAsync(() => { - const file1 = { name: 'newFile1' }; - const file2 = { name: 'newFile2' }; - const event = { target: { files: [file1, file2] } }; - - component.setBackgroundFile(event); - - expect(component.backgroundFile).toEqual(file1); - expect(component.backgroundFileName).toBe(file1.name); - - const newPath = 'alwaysGoYourPath'; - const mockReturnValue = Promise.resolve({ path: newPath } as FileUploadResponse); - jest.spyOn(uploadService, 'uploadFile').mockReturnValue(mockReturnValue); - - component.uploadBackground(); - tick(); - - expect(component.backgroundFile).toBeUndefined(); - expect(component.question.backgroundFilePath).toBe(newPath); - expect(component.isUploadingBackgroundFile).toBeFalse(); - })); - it('should move the mouse in different situations', () => { const event1 = { pageX: 10, pageY: 10 }; const event2 = { clientX: -10, clientY: -10 }; @@ -212,8 +219,6 @@ describe('DragAndDropQuestionEditComponent', () => { it('should move mouse up', () => { component.draggingState = DragState.MOVE; - const questionUpdatedSpy = jest.spyOn(component.questionUpdated, 'emit'); - component.mouseUp(); expect(questionUpdatedSpy).toHaveBeenCalledOnce(); @@ -323,76 +328,38 @@ describe('DragAndDropQuestionEditComponent', () => { }); it('should add text item', () => { - const questionUpdatedSpy = jest.spyOn(component.questionUpdated, 'emit'); - const dragItem = new DragItem(); - dragItem.text = 'Text'; - component.addTextDragItem(); expect(questionUpdatedSpy).toHaveBeenCalledOnce(); - const firstDragItemOfQuestion = component.question.dragItems![0]; - expect(firstDragItemOfQuestion.text).toBe('Text'); + expect(component.question.dragItems).toBeArrayOfSize(1); + const newDragItemOfQuestion = component.question.dragItems![0]; + expect(newDragItemOfQuestion.text).toBe('Text'); + expect(newDragItemOfQuestion.pictureFilePath).toBeUndefined(); + expect(component.filePreviewPaths.size).toBe(0); + expect(addFileSpy).not.toHaveBeenCalled(); + expect(removeFileSpy).not.toHaveBeenCalled(); }); - it('should set drag item text', () => { - const file1 = { name: 'newDragFile1' }; - const file2 = { name: 'newDragFile2' }; - const event = { target: { files: [file1, file2] } }; - - component.setDragItemFile(event); - - expect(component.dragItemFile).toEqual(file1); - expect(component.dragItemFileName).toBe('newDragFile1'); + it('should create image item', () => { + const extension = 'png'; + const fileName = 'testFile.' + extension; + const expectedPath = 'some/client/dependent/path/' + fileName; + const file = new File([], fileName); + + component.createImageDragItem({ target: { files: [file] } }); + + expect(component.question.dragItems).toBeArrayOfSize(1); + const newDragItemOfQuestion = component.question.dragItems![0]; + expect(newDragItemOfQuestion.text).toBeUndefined(); + expect(newDragItemOfQuestion.pictureFilePath).toBeDefined(); + expect(newDragItemOfQuestion.pictureFilePath).toEndWith('.' + extension); + const filePath = newDragItemOfQuestion.pictureFilePath!; + expect(component.filePreviewPaths.size).toBe(1); + expect(component.filePreviewPaths.get(filePath)).toBe(expectedPath); + expect(addFileSpy).toHaveBeenCalledExactlyOnceWith({ file, fileName: filePath }); + expect(removeFileSpy).not.toHaveBeenCalled(); }); - it('should upload drag item', fakeAsync(() => { - const questionUpdatedSpy = jest.spyOn(component.questionUpdated, 'emit'); - try { - component.dragItemFile = new File([], 'file'); - - const newPath = 'alwaysGoYourPath'; - let mockReturnValue = Promise.resolve({ path: newPath }); - const uploadFileSpy = jest.spyOn(uploadService, 'uploadFile'); - uploadFileSpy.mockReturnValue(mockReturnValue); - - component.uploadDragItem(); - tick(); - - const expectedItem = component.question.dragItems![0]; - expect(expectedItem!.pictureFilePath).toBe('alwaysGoYourPath'); - expect(questionUpdatedSpy).toHaveBeenCalledOnce(); - expect(component.dragItemFileName).toBe(''); - expect(component.dragItemFile).toBeUndefined(); - jest.restoreAllMocks(); - - mockReturnValue = Promise.reject({ path: newPath }); - uploadFileSpy.mockReturnValue(mockReturnValue); - component.dragItemFile = new File([], 'file'); - - component.uploadDragItem(); - tick(); - } catch (error) { - expect(component.isUploadingDragItemFile).toBeFalse(); - // Once because spy has been called in first execution of uploadDragItem() - expect(questionUpdatedSpy).toHaveBeenCalledOnce(); - } - })); - - it('should picture for drag item', fakeAsync(() => { - component.dragItemFile = new File([], 'file'); - const newPath = 'alwaysGoYourPath'; - const mockReturnValue = Promise.resolve({ path: newPath } as FileUploadResponse); - jest.spyOn(uploadService, 'uploadFile').mockReturnValue(mockReturnValue); - const questionUpdatedSpy = jest.spyOn(component.questionUpdated, 'emit'); - - component.uploadPictureForDragItemChange(); - tick(); - - expect(questionUpdatedSpy).toHaveBeenCalledOnce(); - expect(component.dragItemPicture).toBe(newPath); - expect(component.isUploadingDragItemFile).toBeFalse(); - })); - it('should delete drag item', () => { const item = new DragItem(); const newItem = new DragItem(); @@ -418,7 +385,6 @@ describe('DragAndDropQuestionEditComponent', () => { }); it('should drop a drag item on a drop location', () => { - const questionUpdatedSpy = jest.spyOn(component.questionUpdated, 'emit'); const location = new DropLocation(); const item = new DragItem(); item.id = 2; @@ -440,7 +406,37 @@ describe('DragAndDropQuestionEditComponent', () => { expect(component.question.correctMappings).toEqual([mapping, expectedMapping]); }); - it('should get mapping for drag item', () => { + it('should get mapping index for mapping', () => { + const item1 = new DragItem(); + const item2 = new DragItem(); + const item3 = new DragItem(); + const item4 = new DragItem(); + const location1 = new DropLocation(); + const location2 = new DropLocation(); + const location3 = new DropLocation(); + const location4 = new DropLocation(); + const mapping1 = new DragAndDropMapping(item1, location1); + const mapping2 = new DragAndDropMapping(item2, location2); + const mapping3 = new DragAndDropMapping(item3, location3); + const mapping4 = new DragAndDropMapping(item4, location4); // unused mapping + component.question.correctMappings = [mapping1, mapping2, mapping3]; + + expect(component.getMappingIndex(mapping1)).toBe(1); + expect(component.getMappingIndex(mapping2)).toBe(2); + expect(component.getMappingIndex(mapping3)).toBe(3); + expect(component.getMappingIndex(mapping4)).toBe(0); + }); + + it('should get mappings for drop location', () => { + const item = new DragItem(); + const location = new DropLocation(); + const mapping = new DragAndDropMapping(item, location); + component.question.correctMappings = [mapping]; + + expect(component.getMappingsForDropLocation(location)).toEqual([mapping]); + }); + + it('should get mappings for drag item', () => { const item = new DragItem(); const location = new DropLocation(); const mapping = new DragAndDropMapping(item, location); @@ -450,30 +446,64 @@ describe('DragAndDropQuestionEditComponent', () => { }); it('should change picture drag item to text drag item', () => { - const item = new DragItem(); - const componentClone = clone(component); + component.question = clone(question3); + component.ngOnInit(); + component.ngAfterViewInit(); - component.changeToTextDragItem(item); + component.changeToTextDragItem(component.question.dragItems![1]); - expect(component).toStrictEqual(componentClone); + expect(questionUpdatedSpy).toHaveBeenCalledOnce(); + expect(component.filePreviewPaths.size).toBe(3); + expect(addFileSpy).not.toHaveBeenCalled(); + expect(removeFileSpy).toHaveBeenCalledExactlyOnceWith('this/is/a/fake/path/3/image.jpg'); + expect(component.question.dragItems![0]).toContainAllEntries([ + ['id', 1], + ['pictureFilePath', 'this/is/a/fake/path/2/image.jpg'], + ['text', undefined], + ]); + expect(component.question.dragItems![1]).toContainAllEntries([ + ['id', 2], + ['pictureFilePath', undefined], + ['text', 'Text'], + ]); + expect(component.question.dragItems![2]).toContainAllEntries([ + ['id', 3], + ['pictureFilePath', 'this/is/a/fake/path/4/image.jpg'], + ['text', undefined], + ]); }); - it('should change text drag item to picture drag item', fakeAsync(() => { - const questionUpdatedSpy = jest.spyOn(component.questionUpdated, 'emit'); - const newPath = 'alwaysGoYourPath'; - const mockReturnValue = Promise.resolve({ path: newPath } as FileUploadResponse); - jest.spyOn(uploadService, 'uploadFile').mockReturnValue(mockReturnValue); - const item = new DragItem(); - component.dragItemFile = new File([], 'file'); - component.dragItemPicture = 'picturePath'; + it('should change text drag item to picture drag item', () => { + component.question = question4; + component.ngOnInit(); + component.ngAfterViewInit(); - component.changeToPictureDragItem(item); - tick(); + const extension = 'png'; + const fileName = 'testFile.' + extension; + const expectedPath = 'some/client/dependent/path/' + fileName; + const file = new File([], fileName); + + component.changeToPictureDragItem(component.question.dragItems![1], { target: { files: [file] } }); - expect(component.dragItemPicture).toBe(newPath); expect(questionUpdatedSpy).toHaveBeenCalledOnce(); - expect(component.isUploadingDragItemFile).toBeFalse(); - })); + expect(component.question.dragItems![0]).toContainAllEntries([ + ['id', 1], + ['pictureFilePath', undefined], + ['text', 'Text1'], + ]); + expect(component.question.dragItems![2]).toContainAllEntries([ + ['id', 3], + ['pictureFilePath', undefined], + ['text', 'Text3'], + ]); + expect(component.question.dragItems![1].text).toBeUndefined(); + expect(component.question.dragItems![1].pictureFilePath).toBeDefined(); + expect(component.question.dragItems![1].pictureFilePath).toEndWith('.' + extension); + const filePath = component.question.dragItems![1].pictureFilePath!; + expect(component.filePreviewPaths.get(filePath)).toBe(expectedPath); + expect(addFileSpy).toHaveBeenCalledExactlyOnceWith({ file, fileName: filePath }); + expect(removeFileSpy).not.toHaveBeenCalled(); + }); it('should change question title', () => { const title = 'backupQuestionTitle'; @@ -562,7 +592,6 @@ describe('DragAndDropQuestionEditComponent', () => { }); it('should detect changes in markdown and edit accordingly', () => { - const questionUpdatedSpy = jest.spyOn(component.questionUpdated, 'emit'); component.question = new DragAndDropQuestion(); component.question.text = 'should be removed'; diff --git a/src/test/javascript/spec/component/exercises/quiz/manage/quiz-question-list-edit-existing.component.spec.ts b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-question-list-edit-existing.component.spec.ts index 781654fe3225..1557bb5fc672 100644 --- a/src/test/javascript/spec/component/exercises/quiz/manage/quiz-question-list-edit-existing.component.spec.ts +++ b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-question-list-edit-existing.component.spec.ts @@ -29,7 +29,7 @@ import { AnswerOption } from 'app/entities/quiz/answer-option.model'; import { ChangeDetectorRef, EventEmitter } from '@angular/core'; import { QuizQuestion } from 'app/entities/quiz/quiz-question.model'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; -import { FileUploaderService } from 'app/shared/http/file-uploader.service'; +import { FileService } from 'app/shared/http/file.service'; const createValidMCQuestion = () => { const question = new MultipleChoiceQuestion(); @@ -99,7 +99,7 @@ describe('QuizQuestionListEditExistingComponent', () => { let courseService: CourseManagementService; let examService: ExamManagementService; let quizExerciseService: QuizExerciseService; - let fileUploaderService: FileUploaderService; + let fileService: FileService; let changeDetector: ChangeDetectorRef; let modalService: NgbModal; @@ -115,7 +115,7 @@ describe('QuizQuestionListEditExistingComponent', () => { examService = fixture.debugElement.injector.get(ExamManagementService); courseService = fixture.debugElement.injector.get(CourseManagementService); quizExerciseService = fixture.debugElement.injector.get(QuizExerciseService); - fileUploaderService = TestBed.inject(FileUploaderService); + fileService = TestBed.inject(FileService); changeDetector = fixture.debugElement.injector.get(ChangeDetectorRef); modalService = fixture.debugElement.injector.get(NgbModal); component = fixture.componentInstance; @@ -418,17 +418,33 @@ describe('QuizQuestionListEditExistingComponent', () => { question1.solutions = [solution]; question1.correctMappings = [new ShortAnswerMapping(spot, solution)]; question1.invalid = false; + + const dragItemFileName1 = 'item1.jpg'; + const dragItemFileName2 = 'item2.jpg'; + const backgroundFileName = 'background.png'; + const dragItemFile1 = new File([''], dragItemFileName1); + const dragItemFile2 = new File([''], dragItemFileName2); + const backgroundFile = new File([''], backgroundFileName); const question2 = new DragAndDropQuestion(); - const dropLocation = new DropLocation(); - question2.dropLocations = [dropLocation]; - const dragItem = new DragItem(); - question2.dragItems = [dragItem]; - question2.correctMappings = [new DragAndDropMapping(dragItem, dropLocation)]; + question2.backgroundFilePath = backgroundFileName; + const dropLocation1 = new DropLocation(); + const dropLocation2 = new DropLocation(); + question2.dropLocations = [dropLocation1, dropLocation2]; + const dragItem1 = { id: 14, pictureFilePath: dragItemFileName1, invalid: false } as DragItem; + const dragItem2 = { id: 15, pictureFilePath: dragItemFileName2, invalid: false } as DragItem; + question2.dragItems = [dragItem1, dragItem2]; + question2.correctMappings = [ + { dragItem: { id: 14, pictureFilePath: dragItemFileName1 } as DragItem, dropLocation: dropLocation1, invalid: false }, + { dragItem: { id: 15, pictureFilePath: dragItemFileName2 } as DragItem, dropLocation: dropLocation2, invalid: false }, + ]; const onQuestionsAddedSpy = jest.spyOn(component.onQuestionsAdded, 'emit').mockImplementation(); + const onFilesAddedSpy = jest.spyOn(component.onFilesAdded, 'emit').mockImplementation(); + const getFileMock = jest.spyOn(fileService, 'getFile').mockResolvedValueOnce(backgroundFile).mockResolvedValueOnce(dragItemFile1).mockResolvedValueOnce(dragItemFile2); const questions = [question0, question1, question2]; - jest.spyOn(fileUploaderService, 'duplicateFile').mockReturnValue(Promise.resolve({ path: 'test' })); await component.addQuestions(questions); expect(onQuestionsAddedSpy).toHaveBeenCalledOnce(); + expect(onFilesAddedSpy).toHaveBeenCalledOnce(); + expect(getFileMock).toHaveBeenCalledTimes(3); }); }); }); diff --git a/src/test/javascript/spec/component/exercises/quiz/manage/quiz-question-list-edit.component.spec.ts b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-question-list-edit.component.spec.ts index 11fcc4e51f4b..b596c65ce5b0 100644 --- a/src/test/javascript/spec/component/exercises/quiz/manage/quiz-question-list-edit.component.spec.ts +++ b/src/test/javascript/spec/component/exercises/quiz/manage/quiz-question-list-edit.component.spec.ts @@ -15,6 +15,13 @@ describe('QuizQuestionListEditComponent', () => { let fixture: ComponentFixture; let component: QuizQuestionListEditComponent; + const fileName1 = 'test1.jpg'; + const file1 = new File([], fileName1); + const fileName2 = 'test2.jpg'; + const file2 = new File([], fileName2); + const fileName3 = 'test3.png'; + const file3 = new File([], fileName3); + beforeEach(() => { TestBed.configureTestingModule({ imports: [CommonModule, ArtemisTestModule, HttpClientTestingModule], @@ -90,4 +97,32 @@ describe('QuizQuestionListEditComponent', () => { expect(component.quizQuestions).toBeArrayOfSize(1); expect(component.quizQuestions[0]).toEqual(question1); }); + + it('should add file', () => { + const path = 'this/is/a/path/to/a/file.png'; + component.handleFileAdded({ fileName: fileName1, file: file1 }); + component.handleFileAdded({ fileName: fileName2, file: file2, path }); + + expect(component.fileMap).toEqual( + new Map([ + [fileName1, { file: file1 }], + [fileName2, { file: file2, path }], + ]), + ); + }); + + it('should remove file', () => { + component.fileMap = new Map([ + [fileName1, { file: file1 }], + [fileName2, { file: file2 }], + [fileName3, { file: file3 }], + ]); + component.handleFileRemoved(fileName2); + expect(component.fileMap).toEqual( + new Map([ + [fileName1, { file: file1 }], + [fileName3, { file: file3 }], + ]), + ); + }); }); diff --git a/src/test/javascript/spec/component/exercises/quiz/manage/re-evaluate/re-evaluate-drag-and-drop-question.component.spec.ts b/src/test/javascript/spec/component/exercises/quiz/manage/re-evaluate/re-evaluate-drag-and-drop-question.component.spec.ts index 76923333b0df..ed780c1a57e2 100644 --- a/src/test/javascript/spec/component/exercises/quiz/manage/re-evaluate/re-evaluate-drag-and-drop-question.component.spec.ts +++ b/src/test/javascript/spec/component/exercises/quiz/manage/re-evaluate/re-evaluate-drag-and-drop-question.component.spec.ts @@ -9,6 +9,13 @@ describe('ReEvaluateDragAndDropQuestionComponent', () => { let fixture: ComponentFixture; let component: ReEvaluateDragAndDropQuestionComponent; + const fileName1 = 'test1.jpg'; + const file1 = new File([], fileName1); + const fileName2 = 'test2.jpg'; + const file2 = new File([], fileName2); + const fileName3 = 'test3.png'; + const file3 = new File([], fileName3); + beforeEach(() => { TestBed.configureTestingModule({ imports: [ArtemisTestModule], @@ -26,8 +33,31 @@ describe('ReEvaluateDragAndDropQuestionComponent', () => { jest.restoreAllMocks(); }); - it('should initialize component', () => { - fixture.detectChanges(); - expect(component).not.toBeNull(); + it('should add file', () => { + const path = 'this/is/a/path/to/a/file.png'; + component.handleAddFile({ fileName: fileName1, file: file1 }); + component.handleAddFile({ fileName: fileName2, file: file2, path }); + + expect(component.fileMap).toEqual( + new Map([ + [fileName1, { file: file1 }], + [fileName2, { file: file2, path }], + ]), + ); + }); + + it('should remove file', () => { + component.fileMap = new Map([ + [fileName1, { file: file1 }], + [fileName2, { file: file2 }], + [fileName3, { file: file3 }], + ]); + component.handleRemoveFile(fileName2); + expect(component.fileMap).toEqual( + new Map([ + [fileName1, { file: file1 }], + [fileName3, { file: file3 }], + ]), + ); }); }); diff --git a/src/test/javascript/spec/component/file-upload-submission/file-upload-submission.component.spec.ts b/src/test/javascript/spec/component/file-upload-submission/file-upload-submission.component.spec.ts index 7d4db3d8ac14..6991b1a0da9c 100644 --- a/src/test/javascript/spec/component/file-upload-submission/file-upload-submission.component.spec.ts +++ b/src/test/javascript/spec/component/file-upload-submission/file-upload-submission.component.spec.ts @@ -23,7 +23,6 @@ import { MAX_SUBMISSION_FILE_SIZE } from 'app/shared/constants/input.constants'; import { TranslateModule } from '@ngx-translate/core'; import dayjs from 'dayjs/esm'; import { of } from 'rxjs'; -import { FileUploaderService } from 'app/shared/http/file-uploader.service'; import { StudentParticipation } from 'app/entities/participation/student-participation.model'; import { Result } from 'app/entities/result.model'; import { FileUploadSubmissionService } from 'app/exercises/file-upload/participate/file-upload-submission.service'; @@ -48,7 +47,6 @@ describe('FileUploadSubmissionComponent', () => { let fixture: ComponentFixture; let debugElement: DebugElement; let router: Router; - let fileUploaderService: FileUploaderService; let alertService: AlertService; let fileUploadSubmissionService: FileUploadSubmissionService; @@ -89,7 +87,6 @@ describe('FileUploadSubmissionComponent', () => { fixture.ngZone!.run(() => { router.initialNavigation(); }); - fileUploaderService = TestBed.inject(FileUploaderService); alertService = TestBed.inject(AlertService); fileUploadSubmissionService = debugElement.injector.get(FileUploadSubmissionService); }); @@ -131,7 +128,6 @@ describe('FileUploadSubmissionComponent', () => { fixture.detectChanges(); let submitFileButton = debugElement.query(By.css('jhi-button')); - jest.spyOn(fileUploaderService, 'uploadFile').mockReturnValue(Promise.resolve({ path: 'test' })); submitFileButton.nativeElement.click(); comp.submission!.submitted = true; comp.result = new Result(); diff --git a/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-detail.component.spec.ts b/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-detail.component.spec.ts index 4d097928729f..6843bea59fb4 100644 --- a/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-detail.component.spec.ts +++ b/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-detail.component.spec.ts @@ -983,15 +983,26 @@ describe('QuizExercise Management Detail Component', () => { }); }); + it('should delete a question', () => { + const mcQuestion = createValidMCQuestion().question; + const dndQuestion = createValidDnDQuestion().question; + comp.quizExercise = { ...quizExercise, quizQuestions: [mcQuestion, dndQuestion] } as QuizExercise; + comp.deleteQuestion(dndQuestion); + expect(comp.quizExercise.quizQuestions).toEqual([mcQuestion]); + }); + describe('saving', () => { let quizExerciseServiceCreateStub: jest.SpyInstance; let quizExerciseServiceUpdateStub: jest.SpyInstance; - let exerciseStub: jest.SpyInstance; + let quizExerciseServiceImportStub: jest.SpyInstance; + let exerciseSanitizeSpy: jest.SpyInstance; const saveQuizWithPendingChangesCache = () => { comp.cacheValidation(); comp.pendingChangesCache = true; - comp.quizQuestionsEditComponent = new QuizQuestionListEditComponent(); - jest.spyOn(comp.quizQuestionsEditComponent, 'parseAllQuestions').mockImplementation(); + if (comp.courseId) { + comp.quizQuestionListEditComponent = new QuizQuestionListEditComponent(); + jest.spyOn(comp.quizQuestionListEditComponent, 'parseAllQuestions').mockImplementation(); + } comp.save(); }; @@ -1005,13 +1016,17 @@ describe('QuizExercise Management Detail Component', () => { }; beforeEach(() => { + comp.course = course; + comp.courseId = course.id!; resetQuizExercise(); comp.quizExercise = quizExercise; quizExerciseServiceCreateStub = jest.spyOn(quizExerciseService, 'create'); quizExerciseServiceCreateStub.mockReturnValue(of(new HttpResponse({ body: quizExercise }))); quizExerciseServiceUpdateStub = jest.spyOn(quizExerciseService, 'update'); quizExerciseServiceUpdateStub.mockReturnValue(of(new HttpResponse({ body: quizExercise }))); - exerciseStub = jest.spyOn(Exercise, 'sanitize'); + quizExerciseServiceImportStub = jest.spyOn(quizExerciseService, 'import'); + quizExerciseServiceImportStub.mockReturnValue(of(new HttpResponse({ body: quizExercise }))); + exerciseSanitizeSpy = jest.spyOn(Exercise, 'sanitize'); }); afterEach(() => { @@ -1021,42 +1036,62 @@ describe('QuizExercise Management Detail Component', () => { it('should call create if valid and quiz exercise no id', () => { comp.quizExercise.id = undefined; saveQuizWithPendingChangesCache(); - expect(exerciseStub).toHaveBeenCalledWith(comp.quizExercise); + expect(exerciseSanitizeSpy).toHaveBeenCalledWith(comp.quizExercise); expect(quizExerciseServiceCreateStub).toHaveBeenCalledOnce(); expect(quizExerciseServiceUpdateStub).not.toHaveBeenCalled(); + expect(quizExerciseServiceImportStub).not.toHaveBeenCalled(); }); it('should call not update if testruns exist in exam mode', () => { comp.quizExercise.testRunParticipationsExist = true; comp.isExamMode = true; saveQuizWithPendingChangesCache(); - expect(exerciseStub).not.toHaveBeenCalledWith(comp.quizExercise); + expect(exerciseSanitizeSpy).not.toHaveBeenCalledWith(comp.quizExercise); expect(quizExerciseServiceCreateStub).not.toHaveBeenCalled(); expect(quizExerciseServiceUpdateStub).not.toHaveBeenCalled(); - expect(quizExerciseServiceUpdateStub).not.toHaveBeenCalledWith(comp.quizExercise, {}); + expect(quizExerciseServiceImportStub).not.toHaveBeenCalled(); }); it('should update if valid and quiz exercise has id', () => { saveQuizWithPendingChangesCache(); - expect(exerciseStub).toHaveBeenCalledWith(comp.quizExercise); + expect(exerciseSanitizeSpy).toHaveBeenCalledWith(comp.quizExercise); + expect(quizExerciseServiceCreateStub).not.toHaveBeenCalled(); + expect(quizExerciseServiceUpdateStub).toHaveBeenCalledExactlyOnceWith(comp.quizExercise.id, comp.quizExercise, new Map(), {}); + expect(quizExerciseServiceImportStub).not.toHaveBeenCalled(); + }); + + it('should import if valid and quiz exercise has id and flag', () => { + comp.isImport = true; + saveQuizWithPendingChangesCache(); + expect(exerciseSanitizeSpy).toHaveBeenCalledWith(comp.quizExercise); expect(quizExerciseServiceCreateStub).not.toHaveBeenCalled(); - expect(quizExerciseServiceUpdateStub).toHaveBeenCalledOnce(); - expect(quizExerciseServiceUpdateStub).toHaveBeenCalledWith(comp.quizExercise, {}); + expect(quizExerciseServiceUpdateStub).not.toHaveBeenCalled(); + expect(quizExerciseServiceImportStub).toHaveBeenCalledExactlyOnceWith(comp.quizExercise, new Map()); }); it('should not save if not valid', () => { comp.quizIsValid = false; comp.pendingChangesCache = true; comp.save(); - expect(exerciseStub).not.toHaveBeenCalled(); + expect(exerciseSanitizeSpy).not.toHaveBeenCalled(); expect(quizExerciseServiceCreateStub).not.toHaveBeenCalled(); expect(quizExerciseServiceUpdateStub).not.toHaveBeenCalled(); + expect(quizExerciseServiceImportStub).not.toHaveBeenCalled(); }); it('should call update with notification text if there is one', () => { comp.notificationText = 'test'; saveQuizWithPendingChangesCache(); - expect(quizExerciseServiceUpdateStub).toHaveBeenCalledWith(comp.quizExercise, { notificationText: 'test' }); + expect(exerciseSanitizeSpy).toHaveBeenCalledWith(comp.quizExercise); + expect(quizExerciseServiceCreateStub).not.toHaveBeenCalled(); + expect(quizExerciseServiceUpdateStub).toHaveBeenCalledWith(comp.quizExercise.id, comp.quizExercise, new Map(), { notificationText: 'test' }); + expect(quizExerciseServiceImportStub).not.toHaveBeenCalled(); + }); + + it('should call alert service if response has no body on create', () => { + comp.quizExercise.id = undefined; + quizExerciseServiceCreateStub.mockReturnValue(of(new HttpResponse({}))); + saveAndExpectAlertService(); }); it('should call alert service if response has no body on update', () => { @@ -1064,9 +1099,15 @@ describe('QuizExercise Management Detail Component', () => { saveAndExpectAlertService(); }); - it('should call alert service if response has no body on create', () => { + it('should call alert service if response has no body on import', () => { + comp.isImport = true; + quizExerciseServiceImportStub.mockReturnValue(of(new HttpResponse({}))); + saveAndExpectAlertService(); + }); + + it('should call alert service if create fails', () => { comp.quizExercise.id = undefined; - quizExerciseServiceCreateStub.mockReturnValue(of(new HttpResponse({}))); + quizExerciseServiceCreateStub.mockReturnValue(throwError(() => ({ status: 404 }))); saveAndExpectAlertService(); }); @@ -1075,9 +1116,9 @@ describe('QuizExercise Management Detail Component', () => { saveAndExpectAlertService(); }); - it('should call alert service if response is 404', () => { - comp.quizExercise.id = undefined; - quizExerciseServiceCreateStub.mockReturnValue(throwError(() => ({ status: 404 }))); + it('should call alert service if import fails', () => { + comp.isImport = true; + quizExerciseServiceImportStub.mockReturnValue(throwError(() => ({ status: 404 }))); saveAndExpectAlertService(); }); }); diff --git a/src/test/javascript/spec/component/shared/http/file.service.spec.ts b/src/test/javascript/spec/component/shared/http/file.service.spec.ts new file mode 100644 index 000000000000..c504ac111084 --- /dev/null +++ b/src/test/javascript/spec/component/shared/http/file.service.spec.ts @@ -0,0 +1,79 @@ +import { FileService } from 'app/shared/http/file.service'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { v4 as uuid } from 'uuid'; + +jest.mock('uuid', () => ({ + v4: jest.fn(), +})); +describe('FileService', () => { + const firstUniqueFileName = 'someOtherUniqueFileName'; + const secondUniqueFileName = 'someUniqueFileName'; + const thirdUniqueFileName = 'someFinalUniqueFileName'; + + let fileService: FileService; + let httpMock: HttpTestingController; + let getUniqueFileNameSpy: jest.SpyInstance; + let v4Mock: jest.Mock; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [FileService], + }); + fileService = TestBed.inject(FileService); + httpMock = TestBed.inject(HttpTestingController); + getUniqueFileNameSpy = jest.spyOn(fileService, 'getUniqueFileName'); + + v4Mock = uuid as jest.Mock; + v4Mock.mockReturnValueOnce(firstUniqueFileName).mockReturnValueOnce(secondUniqueFileName).mockReturnValueOnce(thirdUniqueFileName); + }); + + afterEach(() => { + v4Mock.mockReset(); + }); + + describe('getFile', () => { + it('should return a file', async () => { + const filePath = 'api/file/path/test.png'; + const blob = new Blob(['123456789']); + + const filePromise = fileService.getFile(filePath); + const req = httpMock.expectOne({ + url: filePath, + method: 'GET', + }); + req.flush(blob); + + const file = await filePromise; + + expect(file.size).toEqual(blob.size); + expect(file.name).toBe(firstUniqueFileName + '.png'); + expect(getUniqueFileNameSpy).toHaveBeenCalledExactlyOnceWith('png', undefined); + expect(v4Mock).toHaveBeenCalledOnce(); + }); + + it('should return a file with unique name', async () => { + const filePath = 'api/file/path/test.png'; + const blob = new Blob(['123456789']); + const existingFileNames = new Map([ + [secondUniqueFileName + '.png', { file: new File([], secondUniqueFileName) }], + [firstUniqueFileName + '.png', { file: new File([], firstUniqueFileName) }], + ]); + + const filePromise = fileService.getFile(filePath, existingFileNames); + const req = httpMock.expectOne({ + url: filePath, + method: 'GET', + }); + req.flush(blob); + + const file = await filePromise; + + expect(file.size).toEqual(blob.size); + expect(file.name).toBe(thirdUniqueFileName + '.png'); + expect(getUniqueFileNameSpy).toHaveBeenCalledExactlyOnceWith('png', existingFileNames); + expect(v4Mock).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/src/test/javascript/spec/service/quiz-exercise.service.spec.ts b/src/test/javascript/spec/service/quiz-exercise.service.spec.ts index 15070405b89f..c96e6b448fdf 100644 --- a/src/test/javascript/spec/service/quiz-exercise.service.spec.ts +++ b/src/test/javascript/spec/service/quiz-exercise.service.spec.ts @@ -1,6 +1,6 @@ import { TranslateService } from '@ngx-translate/core'; import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpClientTestingModule, HttpTestingController, TestRequest } from '@angular/common/http/testing'; import { HttpResponse } from '@angular/common/http'; import { SessionStorageService } from 'ngx-webstorage'; import { QuizExerciseService } from 'app/exercises/quiz/manage/quiz-exercise.service'; @@ -29,9 +29,13 @@ const makeQuiz = () => { }; describe('QuizExercise Service', () => { + const fileMap = new Map(); + fileMap.set('file.jpg', new Blob()); + let service: QuizExerciseService; let httpMock: HttpTestingController; let elemDefault: QuizExercise; + beforeEach(() => { TestBed.configureTestingModule({ imports: [ArtemisTestModule, HttpClientTestingModule], @@ -46,6 +50,11 @@ describe('QuizExercise Service', () => { elemDefault = makeQuiz(); }); + afterEach(() => { + httpMock.verify(); + jest.restoreAllMocks(); + }); + it('should find an element', async () => { const returnedFromService = Object.assign({}, elemDefault); const result = firstValueFrom(service.find(123)); @@ -62,8 +71,34 @@ describe('QuizExercise Service', () => { elemDefault, ); const expected = Object.assign({}, returnedFromService); - const result = firstValueFrom(service.create(new QuizExercise(undefined, undefined))); - const req = httpMock.expectOne({ method: 'POST' }); + const result = firstValueFrom(service.create(new QuizExercise(undefined, undefined), fileMap)); + const req = httpMock.expectOne({ method: 'POST', url: 'api/quiz-exercises' }); + validateFormData(req); + req.flush(returnedFromService); + expect((await result)?.body).toEqual(expected); + }); + + it('should import a QuizExercise', async () => { + const returnedFromService = Object.assign( + { + description: 'BBBBBB', + explanation: 'BBBBBB', + randomizeQuestionOrder: true, + allowedNumberOfAttempts: 1, + isVisibleBeforeStart: true, + isOpenForPractice: true, + isPlannedToStart: true, + duration: 1, + }, + elemDefault, + ); + const quizExercise = new QuizExercise(undefined, undefined); + quizExercise.id = 42; + + const expected = Object.assign({}, returnedFromService); + const result = firstValueFrom(service.import(quizExercise, fileMap)); + const req = httpMock.expectOne({ method: 'POST', url: 'api/quiz-exercises/import/42' }); + validateFormData(req); req.flush(returnedFromService); expect((await result)?.body).toEqual(expected); }); @@ -83,8 +118,9 @@ describe('QuizExercise Service', () => { elemDefault, ); const expected = Object.assign({}, returnedFromService); - const result = firstValueFrom(service.update(expected)); - const req = httpMock.expectOne({ method: 'PUT' }); + const result = firstValueFrom(service.update(1, expected, fileMap)); + const req = httpMock.expectOne({ method: 'PUT', url: 'api/quiz-exercises/1' }); + validateFormData(req); req.flush(returnedFromService); expect((await result)?.body).toEqual(expected); }); @@ -105,7 +141,7 @@ describe('QuizExercise Service', () => { ); const expected = Object.assign({}, returnedFromService); const result = firstValueFrom(service.query()); - const req = httpMock.expectOne({ method: 'GET' }); + const req = httpMock.expectOne({ method: 'GET', url: 'api/quiz-exercises' }); req.flush([returnedFromService]); expect((await result)?.body).toEqual([expected]); }); @@ -219,8 +255,11 @@ describe('QuizExercise Service', () => { } }); - afterEach(() => { - httpMock.verify(); - jest.restoreAllMocks(); - }); + function validateFormData(req: TestRequest) { + expect(req.request.body).toBeInstanceOf(FormData); + expect(req.request.body.get('exercise')).toBeInstanceOf(Blob); + const fileArray = req.request.body.getAll('files'); + expect(fileArray).toBeArrayOfSize(1); + expect(fileArray[0]).toBeInstanceOf(Blob); + } }); diff --git a/src/test/javascript/spec/service/quiz-re-evaluate.service.spec.ts b/src/test/javascript/spec/service/quiz-re-evaluate.service.spec.ts new file mode 100644 index 000000000000..1cba6a320fda --- /dev/null +++ b/src/test/javascript/spec/service/quiz-re-evaluate.service.spec.ts @@ -0,0 +1,43 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { QuizReEvaluateService } from 'app/exercises/quiz/manage/re-evaluate/quiz-re-evaluate.service'; +import { ArtemisTestModule } from '../test.module'; +import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; + +describe('QuizReEvaluateService', () => { + let service: QuizReEvaluateService; + let httpMock: HttpTestingController; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule, HttpClientTestingModule], + providers: [QuizReEvaluateService], + }); + service = TestBed.inject(QuizReEvaluateService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should send reevaluate request correctly', fakeAsync(() => { + const quizExercise = { id: 1 } as QuizExercise; + const files = new Map(); + files.set('test1', new Blob()); + files.set('test2', new Blob()); + service.reevaluate(quizExercise, files).subscribe((res) => { + expect(res.body).toEqual(quizExercise); + }); + + const req = httpMock.expectOne({ method: 'PUT', url: 'api/quiz-exercises/1/re-evaluate' }); + expect(req.request.body).toBeInstanceOf(FormData); + expect(req.request.body.getAll('exercise')).toBeArrayOfSize(1); + expect(req.request.body.get('exercise')).toBeInstanceOf(Blob); + const formDataFiles = req.request.body.getAll('files'); + expect(formDataFiles).toBeArrayOfSize(2); + expect(formDataFiles[0]).toBeInstanceOf(Blob); + expect(formDataFiles[1]).toBeInstanceOf(Blob); + req.flush(quizExercise); + tick(); + })); +}); diff --git a/src/test/javascript/spec/util/modeling/test-models/class-diagram.json b/src/test/javascript/spec/util/modeling/test-models/class-diagram.json index ffa6a993f117..7172141e32a8 100644 --- a/src/test/javascript/spec/util/modeling/test-models/class-diagram.json +++ b/src/test/javascript/spec/util/modeling/test-models/class-diagram.json @@ -7,7 +7,7 @@ }, "interactive": { "elements": ["b390a813-dad9-4d6d-b3cd-732ce99d0a23", "6f572312-066b-4678-9c03-5032f3ba9be9", "2f67120e-b491-4222-beb1-79e87c2cf54d"], - "relationships": [] + "relationships": ["5a9a4eb3-8281-4de4-b0f2-3e2f164574bd"] }, "elements": [ {