diff --git a/src/main/java/de/tum/in/www1/artemis/service/LectureUnitProcessingService.java b/src/main/java/de/tum/in/www1/artemis/service/LectureUnitProcessingService.java index fc04fa5bf323..7cbbc03a3cbf 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/LectureUnitProcessingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/LectureUnitProcessingService.java @@ -1,11 +1,14 @@ package de.tum.in.www1.artemis.service; import java.io.*; +import java.nio.file.Path; import java.time.ZonedDateTime; import java.util.*; import javax.validation.constraints.NotNull; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.FilenameUtils; import org.apache.pdfbox.Loader; import org.apache.pdfbox.multipdf.Splitter; import org.apache.pdfbox.pdmodel.PDDocument; @@ -38,6 +41,11 @@ public class LectureUnitProcessingService { private final AttachmentUnitService attachmentUnitService; + private final PDFTextStripper pdfTextStripper = new PDFTextStripper(); + + // A pdf splitter that should be used to split a file into single pages + private final Splitter pdfSinglePageSplitter = new Splitter(); + public LectureUnitProcessingService(SlideSplitterService slideSplitterService, FileService fileService, LectureRepository lectureRepository, AttachmentUnitService attachmentUnitService) { this.fileService = fileService; @@ -50,15 +58,14 @@ public LectureUnitProcessingService(SlideSplitterService slideSplitterService, F * Split units from given file according to given split information and saves them. * * @param lectureUnitInformationDTO The split information - * @param file The file (lecture slide) to be split + * @param fileBytes The byte content of the file (lecture slides) to be split * @param lecture The lecture that the attachment unit belongs to * @return The prepared units to be saved */ - public List splitAndSaveUnits(LectureUnitInformationDTO lectureUnitInformationDTO, MultipartFile file, Lecture lecture) throws IOException { + public List splitAndSaveUnits(LectureUnitInformationDTO lectureUnitInformationDTO, byte[] fileBytes, Lecture lecture) throws IOException { - try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); PDDocument document = Loader.loadPDF(file.getBytes())) { + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); PDDocument document = Loader.loadPDF(fileBytes)) { List units = new ArrayList<>(); - Splitter pdfSplitter = new Splitter(); for (LectureUnitSplitDTO lectureUnit : lectureUnitInformationDTO.units()) { // make sure output stream doesn't contain old data @@ -67,7 +74,7 @@ public List splitAndSaveUnits(LectureUnitInformationDTO lectureU AttachmentUnit attachmentUnit = new AttachmentUnit(); Attachment attachment = new Attachment(); PDDocumentInformation pdDocumentInformation = new PDDocumentInformation(); - + Splitter pdfSplitter = new Splitter(); pdfSplitter.setStartPage(lectureUnit.startPage()); pdfSplitter.setEndPage(lectureUnit.endPage()); // split only based on start and end page @@ -100,28 +107,60 @@ public List splitAndSaveUnits(LectureUnitInformationDTO lectureU } } + /** + * Gets the slides that should be removed by the given keyphrase + * + * @param fileBytes The byte content of the file (lecture slides) to be split + * @param commaSeparatedKeyphrases key phrases that identify slides about to be removed + * @return list of the number of slides that will be removed + */ + public List getSlidesToRemoveByKeyphrase(byte[] fileBytes, String commaSeparatedKeyphrases) { + List slidesToRemove = new ArrayList<>(); + if (commaSeparatedKeyphrases.isEmpty()) { + return slidesToRemove; + } + try (PDDocument document = Loader.loadPDF(fileBytes)) { + List pages = pdfSinglePageSplitter.split(document); + List keyphrasesList = getKeyphrasesFromString(commaSeparatedKeyphrases); + + for (int index = 0; index < pages.size(); index++) { + try (PDDocument currentPage = pages.get(index)) { + String slideText = pdfTextStripper.getText(currentPage); + + if (slideContainsKeyphrase(slideText, keyphrasesList)) { + slidesToRemove.add(index); + } + } + } + } + catch (IOException e) { + log.error("Error while retrieving slides to remove from document", e); + throw new InternalServerErrorException("Error while retrieving slides to remove from document"); + } + return slidesToRemove; + } + /** * Removes the slides containing any of the key phrases from the given document. * - * @param document document to remove slides from - * @param removeSlidesCommaSeparatedKeyPhrases key phrases that identify slides about to be removed + * @param document document to remove slides from + * @param commaSeparatedKeyphrases keyphrases that identify slides about to be removed */ - private void removeSlidesContainingAnyKeyPhrases(PDDocument document, String removeSlidesCommaSeparatedKeyPhrases) { + private void removeSlidesContainingAnyKeyPhrases(PDDocument document, String commaSeparatedKeyphrases) { try { - PDFTextStripper pdfTextStripper = new PDFTextStripper(); - Splitter pdfSplitter = new Splitter(); - List pages = pdfSplitter.split(document); + List pages = pdfSinglePageSplitter.split(document); + List keyphrasesList = getKeyphrasesFromString(commaSeparatedKeyphrases); // Uses a decrementing loop (starting from the last index) to ensure that the // index values are adjusted correctly when removing pages. for (int index = pages.size() - 1; index >= 0; index--) { - PDDocument currentPage = pages.get(index); - String slideText = pdfTextStripper.getText(currentPage); + try (PDDocument currentPage = pages.get(index)) { + String slideText = pdfTextStripper.getText(currentPage); - if (slideContainsKeyphrase(slideText, removeSlidesCommaSeparatedKeyPhrases)) { - document.removePage(index); + if (slideContainsKeyphrase(slideText, keyphrasesList)) { + document.removePage(index); + } } - currentPage.close(); // make sure to close the document } } catch (IOException e) { @@ -130,22 +169,22 @@ private void removeSlidesContainingAnyKeyPhrases(PDDocument document, String rem } } - private boolean slideContainsKeyphrase(String slideText, String removeSlidesCommaSeparatedKeyPhrases) { + private boolean slideContainsKeyphrase(String slideText, List keyphrasesList) { String lowerCaseSlideText = slideText.toLowerCase(); - return Arrays.stream(removeSlidesCommaSeparatedKeyPhrases.split(",")).anyMatch(keyphrase -> lowerCaseSlideText.contains(keyphrase.strip().toLowerCase())); + return keyphrasesList.stream().anyMatch(keyphrase -> lowerCaseSlideText.contains(keyphrase.strip().toLowerCase())); } /** * Prepare information of split units for client * - * @param file The file (lecture slide) to be split + * @param fileBytes The byte content of the file (lecture slides) to be split * @return The prepared information of split units LectureUnitInformationDTO */ - public LectureUnitInformationDTO getSplitUnitData(MultipartFile file) { + public LectureUnitInformationDTO getSplitUnitData(byte[] fileBytes) { try { - log.debug("Start preparing information of split units for the file {}", file); - Outline unitsInformation = separateIntoUnits(file); + log.debug("Start preparing information of split units."); + Outline unitsInformation = separateIntoUnits(fileBytes); Map unitsDocumentMap = unitsInformation.splits; int numberOfPages = unitsInformation.totalPages; @@ -161,21 +200,48 @@ public LectureUnitInformationDTO getSplitUnitData(MultipartFile file) { } } + /** + * Temporarily saves a file that will be processed into lecture units. + * + * @param lectureId the id of the lecture the file belongs to + * @param file the file to be saved + * @param minutesUntilDeletion duration the file gets saved for + * @return the last part of the filename. Use {@link LectureUnitProcessingService#getPathForTempFilename(long, String) getPathForTempFilename} + * to get the full file path again. + */ + public String saveTempFileForProcessing(long lectureId, MultipartFile file, int minutesUntilDeletion) throws IOException { + String prefix = "Temp_" + lectureId + "_"; + Path filePath = fileService.generateFilePath(prefix, FilenameUtils.getExtension(file.getOriginalFilename()), FilePathService.getTempFilePath()); + FileUtils.copyInputStreamToFile(file.getInputStream(), filePath.toFile()); + fileService.schedulePathForDeletion(filePath, minutesUntilDeletion); + return filePath.getFileName().toString().substring(prefix.length()); + } + + /** + * Gets the path of the temporary file for a give lectureId and filename + * + * @param lectureId the id of the lecture the file belongs to + * @param filename the last part of the filename (timestamp and extension) + * @return Path of the file + */ + public Path getPathForTempFilename(long lectureId, String filename) { + String fullFilename = "Temp_" + lectureId + "_" + FileService.sanitizeFilename(filename); + return FilePathService.getTempFilePath().resolve(fullFilename); + } + /** * This method prepares a map with information on how the slide * is going to be split. The map looks like the following: * Map * - * @param file The file (lecture pdf) to be split + * @param fileBytes The byte content of the file (lecture pdf) to be split * @return The prepared map */ - private Outline separateIntoUnits(MultipartFile file) throws IOException { - try (PDDocument document = Loader.loadPDF(file.getBytes())) { + private Outline separateIntoUnits(byte[] fileBytes) throws IOException { + try (PDDocument document = Loader.loadPDF(fileBytes)) { Map outlineMap = new HashMap<>(); - Splitter pdfSplitter = new Splitter(); - PDFTextStripper pdfStripper = new PDFTextStripper(); // split the document into single pages - List pages = pdfSplitter.split(document); + List pages = pdfSinglePageSplitter.split(document); int numberOfPages = document.getNumberOfPages(); ListIterator iterator = pages.listIterator(); @@ -183,7 +249,7 @@ private Outline separateIntoUnits(MultipartFile file) throws IOException { int index = 1; while (iterator.hasNext()) { PDDocument currentPage = iterator.next(); - String slideText = pdfStripper.getText(currentPage); + String slideText = pdfTextStripper.getText(currentPage); if (isOutlineSlide(slideText)) { outlineCount++; @@ -231,4 +297,11 @@ private record LectureUnitSplit(String unitName, int startPage, int endPage) { */ private record Outline(Map splits, int totalPages) { } + + /** + * parses a string containing comma-seperated keyphrases into a list of keyphrases. + */ + private List getKeyphrasesFromString(String commaSeparatedKeyphrases) { + return Arrays.stream(commaSeparatedKeyphrases.split(",")).filter(s -> !s.isBlank()).toList(); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/lecture/AttachmentUnitResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/lecture/AttachmentUnitResource.java index b0a781519eed..d9a23c2cf7f4 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/lecture/AttachmentUnitResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/lecture/AttachmentUnitResource.java @@ -3,6 +3,8 @@ import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Objects; @@ -14,6 +16,8 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import com.google.gson.Gson; + import de.tum.in.www1.artemis.domain.Attachment; import de.tum.in.www1.artemis.domain.Lecture; import de.tum.in.www1.artemis.domain.lecture.AttachmentUnit; @@ -21,16 +25,10 @@ import de.tum.in.www1.artemis.repository.LectureRepository; import de.tum.in.www1.artemis.security.Role; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastEditor; -import de.tum.in.www1.artemis.service.AttachmentUnitService; -import de.tum.in.www1.artemis.service.AuthorizationCheckService; -import de.tum.in.www1.artemis.service.CompetencyProgressService; -import de.tum.in.www1.artemis.service.LectureUnitProcessingService; -import de.tum.in.www1.artemis.service.SlideSplitterService; +import de.tum.in.www1.artemis.service.*; import de.tum.in.www1.artemis.service.notifications.GroupNotificationService; import de.tum.in.www1.artemis.web.rest.dto.LectureUnitInformationDTO; -import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; -import de.tum.in.www1.artemis.web.rest.errors.ConflictException; -import de.tum.in.www1.artemis.web.rest.errors.InternalServerErrorException; +import de.tum.in.www1.artemis.web.rest.errors.*; @RestController @RequestMapping("api/") @@ -40,6 +38,8 @@ public class AttachmentUnitResource { private static final String ENTITY_NAME = "attachmentUnit"; + private static final Gson GSON = new Gson(); + private final AttachmentUnitRepository attachmentUnitRepository; private final LectureRepository lectureRepository; @@ -56,9 +56,11 @@ public class AttachmentUnitResource { private final SlideSplitterService slideSplitterService; + private final FileService fileService; + public AttachmentUnitResource(AttachmentUnitRepository attachmentUnitRepository, LectureRepository lectureRepository, LectureUnitProcessingService lectureUnitProcessingService, AuthorizationCheckService authorizationCheckService, GroupNotificationService groupNotificationService, AttachmentUnitService attachmentUnitService, - CompetencyProgressService competencyProgressService, SlideSplitterService slideSplitterService) { + CompetencyProgressService competencyProgressService, SlideSplitterService slideSplitterService, FileService fileService) { this.attachmentUnitRepository = attachmentUnitRepository; this.lectureUnitProcessingService = lectureUnitProcessingService; this.lectureRepository = lectureRepository; @@ -67,6 +69,7 @@ public AttachmentUnitResource(AttachmentUnitRepository attachmentUnitRepository, this.attachmentUnitService = attachmentUnitService; this.competencyProgressService = competencyProgressService; this.slideSplitterService = slideSplitterService; + this.fileService = fileService; } /** @@ -161,30 +164,54 @@ public ResponseEntity createAttachmentUnit(@PathVariable Long le } /** - * POST lectures/:lectureId/attachment-units/split : creates new attachment units. The provided file must be a pdf file. + * POST lectures/:lectureId/attachment-units/upload : Temporarily uploads a file which will be processed into lecture units * - * @param lectureId the id of the lecture to which the attachment units should be added - * @param lectureUnitInformationDTO the units that should be created - * @param file the file to be splitted - * @return the ResponseEntity with status 200 (ok) and with body the newly created attachment units + * @param file the file that will be processed + * @param lectureId the id of the lecture to which the attachment units will be added + * @return the ResponseEntity with status 200 (ok) and with body filename of the uploaded file */ - @PostMapping("lectures/{lectureId}/attachment-units/split") + @PostMapping("lectures/{lectureId}/attachment-units/upload") @EnforceAtLeastEditor - public ResponseEntity> createAttachmentUnits(@PathVariable Long lectureId, @RequestPart LectureUnitInformationDTO lectureUnitInformationDTO, - @RequestPart MultipartFile file) { - log.debug("REST request to create AttachmentUnits {} with lectureId {}", lectureUnitInformationDTO, lectureId); - - Lecture lecture = lectureRepository.findByIdWithLectureUnitsElseThrow(lectureId); - if (lecture.getCourse() == null) { - throw new ConflictException("Specified lecture is not part of a course", "AttachmentUnit", "courseMissing"); + public ResponseEntity uploadSlidesForProcessing(@PathVariable Long lectureId, @RequestPart("file") MultipartFile file) { + // time until the temporary file gets deleted. Must be greater or equal than MINUTES_UNTIL_DELETION in attachment-units.component.ts + int minutesUntilDeletion = 30; + String originalFilename = file.getOriginalFilename(); + log.debug("REST request to upload file: {}", originalFilename); + checkLecture(lectureId); + if (!Objects.equals(FilenameUtils.getExtension(originalFilename), "pdf")) { + throw new BadRequestAlertException("The file must be a pdf", ENTITY_NAME, "wrongFileType"); } - authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, lecture.getCourse(), null); + try { + String filename = lectureUnitProcessingService.saveTempFileForProcessing(lectureId, file, minutesUntilDeletion); + return ResponseEntity.ok().body(GSON.toJson(filename)); + } + catch (IOException e) { + log.error("Could not save file {}", originalFilename, e); + throw new InternalServerErrorException("Could not create file"); + } + } + + /** + * POST lectures/:lectureId/attachment-units/split : creates new attachment units from the given file and lecture unit information + * + * @param lectureId the id of the lecture to which the attachment units will be added + * @param lectureUnitInformationDTO the units that will be created + * @param filename the name of the lecture file, located in the temp folder + * @return the ResponseEntity with status 200 (ok) and with body the newly created attachment units + */ + @PostMapping("lectures/{lectureId}/attachment-units/split/{filename}") + @EnforceAtLeastEditor + public ResponseEntity> createAttachmentUnits(@PathVariable Long lectureId, @RequestBody LectureUnitInformationDTO lectureUnitInformationDTO, + @PathVariable String filename) { + log.debug("REST request to create AttachmentUnits {} with lectureId {} for file {}", lectureUnitInformationDTO, lectureId, filename); + checkLecture(lectureId); + Path filePath = lectureUnitProcessingService.getPathForTempFilename(lectureId, filename); + checkFile(filePath); try { - if (!Objects.equals(FilenameUtils.getExtension(file.getOriginalFilename()), "pdf")) { - throw new BadRequestAlertException("The file must be a pdf", ENTITY_NAME, "wrongFileType"); - } - List savedAttachmentUnits = lectureUnitProcessingService.splitAndSaveUnits(lectureUnitInformationDTO, file, lecture); + byte[] fileBytes = fileService.getFileForPath(filePath); + List savedAttachmentUnits = lectureUnitProcessingService.splitAndSaveUnits(lectureUnitInformationDTO, fileBytes, + lectureRepository.findByIdWithLectureUnitsElseThrow(lectureId)); savedAttachmentUnits.forEach(attachmentUnitService::prepareAttachmentUnitForClient); savedAttachmentUnits.forEach(competencyProgressService::updateProgressByLearningObjectAsync); return ResponseEntity.ok().body(savedAttachmentUnits); @@ -196,25 +223,57 @@ public ResponseEntity> createAttachmentUnits(@PathVariable } /** - * POST lectures/:lectureId/process-units : Prepare attachment units information + * GET lectures/:lectureId/attachment-units : Calculates lecture units by splitting up the given file * - * @param file the file to get the units data - * @param lectureId the id of the lecture to which the file is going to be splitted + * @param lectureId the id of the lecture to which the file is going to be split + * @param filename the name of the lecture file to be split, located in the temp folder * @return the ResponseEntity with status 200 (ok) and with body attachmentUnitsData */ - @PostMapping("lectures/{lectureId}/process-units") + @GetMapping("lectures/{lectureId}/attachment-units/data/{filename}") @EnforceAtLeastEditor - public ResponseEntity getAttachmentUnitsData(@PathVariable Long lectureId, @RequestParam("file") MultipartFile file) { - log.debug("REST request to split lecture file : {}", file.getOriginalFilename()); + public ResponseEntity getAttachmentUnitsData(@PathVariable Long lectureId, @PathVariable String filename) { + log.debug("REST request to split lecture file : {}", filename); - Lecture lecture = lectureRepository.findByIdWithLectureUnitsElseThrow(lectureId); - if (lecture.getCourse() == null) { - throw new ConflictException("Specified lecture is not part of a course", "AttachmentUnit", "courseMissing"); + checkLecture(lectureId); + Path filePath = lectureUnitProcessingService.getPathForTempFilename(lectureId, filename); + checkFile(filePath); + + try { + byte[] fileBytes = fileService.getFileForPath(filePath); + LectureUnitInformationDTO attachmentUnitsData = lectureUnitProcessingService.getSplitUnitData(fileBytes); + return ResponseEntity.ok().body(attachmentUnitsData); } - authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, lecture.getCourse(), null); + catch (IOException e) { + log.error("Could not calculate lecture units automatically", e); + throw new InternalServerErrorException("Could not calculate lecture units automatically"); + } + } - LectureUnitInformationDTO attachmentUnitsData = lectureUnitProcessingService.getSplitUnitData(file); - return ResponseEntity.ok().body(attachmentUnitsData); + /** + * GET lectures/:lectureId/attachment-units/slides-to-remove : gets the slides to be removed + * + * @param lectureId the id of the lecture to which the unit belongs + * @param filename the name of the file to be parsed, located in the temp folder + * @param commaSeparatedKeyPhrases the comma seperated keyphrases to be removed + * @return the ResponseEntity with status 200 (OK) and with body the list of slides to be removed + */ + @GetMapping("lectures/{lectureId}/attachment-units/slides-to-remove/{filename}") + @EnforceAtLeastEditor + public ResponseEntity> getSlidesToRemove(@PathVariable Long lectureId, @PathVariable String filename, @RequestParam String commaSeparatedKeyPhrases) { + log.debug("REST request to get slides to remove for lecture file : {} and keywords : {}", filename, commaSeparatedKeyPhrases); + checkLecture(lectureId); + Path filePath = lectureUnitProcessingService.getPathForTempFilename(lectureId, filename); + checkFile(filePath); + + try { + byte[] fileBytes = fileService.getFileForPath(filePath); + List slidesToRemove = this.lectureUnitProcessingService.getSlidesToRemoveByKeyphrase(fileBytes, commaSeparatedKeyPhrases); + return ResponseEntity.ok().body(slidesToRemove); + } + catch (IOException e) { + log.error("Could not calculate slides to remove", e); + throw new InternalServerErrorException("Could not calculate slides to remove"); + } } /** @@ -231,4 +290,31 @@ private void checkAttachmentUnitCourseAndLecture(AttachmentUnit attachmentUnit, throw new ConflictException("Requested lecture unit is not part of the specified lecture", "AttachmentUnit", "lectureIdMismatch"); } } + + /** + * Checks that the lecture exists and is part of a course, and that the user is at least editor in the course + * + * @param lectureId The id of the lecture + */ + private void checkLecture(Long lectureId) { + Lecture lecture = lectureRepository.findByIdWithLectureUnitsElseThrow(lectureId); + if (lecture.getCourse() == null) { + throw new ConflictException("Specified lecture is not part of a course", "AttachmentUnit", "courseMissing"); + } + authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, lecture.getCourse(), null); + } + + /** + * Checks the file exists on the server and is a .pdf + * + * @param filePath the path of the file + */ + private void checkFile(Path filePath) { + if (!Files.exists(filePath)) { + throw new EntityNotFoundException(ENTITY_NAME, filePath.toString()); + } + if (!filePath.toString().endsWith(".pdf")) { + throw new BadRequestAlertException("The file must be a pdf", ENTITY_NAME, "wrongFileType"); + } + } } diff --git a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/attachment-units/attachment-units.component.html b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/attachment-units/attachment-units.component.html index 87388093f108..c87e80b641d0 100644 --- a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/attachment-units/attachment-units.component.html +++ b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/attachment-units/attachment-units.component.html @@ -79,9 +79,14 @@

{{ 'artemisApp.attachmentUnit.createAttachmentUnits.removeSlides' | artemisTranslate }}
- - {{ 'artemisApp.attachmentUnit.createAttachmentUnits.removeSlidesInfo' | artemisTranslate }} - +
    +
  • {{ 'artemisApp.attachmentUnit.createAttachmentUnits.removeSlidesInfo.firstLine' | artemisTranslate }}
  • +
  • {{ 'artemisApp.attachmentUnit.createAttachmentUnits.removeSlidesInfo.secondLine' | artemisTranslate }}
  • +
  • + {{ 'artemisApp.attachmentUnit.createAttachmentUnits.removeSlidesInfo.thirdLine' | artemisTranslate }} + {{ removedSlidesNumbers.length > 0 ? removedSlidesNumbers : '-' }} +
  • +
id="removeSlidesCommaSeparatedKeyPhrases" placeholder="{{ 'artemisApp.attachmentUnit.createAttachmentUnits.removeSlidesPlaceholder' | artemisTranslate }}" autocomplete="off" - [(ngModel)]="removeSlidesCommaSeparatedKeyPhrases" + [(ngModel)]="searchTerm" />
diff --git a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/attachment-units/attachment-units.component.ts b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/attachment-units/attachment-units.component.ts index 1ae42cb73614..b8d95ef66b6c 100644 --- a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/attachment-units/attachment-units.component.ts +++ b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/attachment-units/attachment-units.component.ts @@ -1,23 +1,24 @@ import { Component, OnInit } from '@angular/core'; -import { faBan, faClock, faExclamationTriangle, faGlobe, faPlus, faQuestionCircle, faSave, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faBan, faExclamationTriangle, faPlus, faQuestionCircle, faSave, faTimes } from '@fortawesome/free-solid-svg-icons'; import { ActivatedRoute, Router } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { onError } from 'app/shared/util/global.utils'; import { AttachmentUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/attachmentUnit.service'; import { combineLatest } from 'rxjs'; import dayjs from 'dayjs/esm'; -import { objectToJsonBlob } from 'app/utils/blob-util'; import { AlertService } from 'app/core/util/alert.service'; import { TranslateService } from '@ngx-translate/core'; +import { Subject } from 'rxjs'; +import { debounceTime, repeat, switchMap } from 'rxjs/operators'; -type LectureUnitDTOS = { +export type LectureUnitDTOS = { unitName: string; releaseDate?: dayjs.Dayjs; startPage: number; endPage: number; }; -type LectureUnitInformationDTO = { +export type LectureUnitInformationDTO = { units: LectureUnitDTOS[]; numberOfPages: number; removeSlidesCommaSeparatedKeyPhrases: string; @@ -37,17 +38,21 @@ export class AttachmentUnitsComponent implements OnInit { numberOfPages: number; faSave = faSave; faBan = faBan; - faGlobe = faGlobe; - faClock = faClock; faTimes = faTimes; faPlus = faPlus; faExclamationTriangle = faExclamationTriangle; faQuestionCircle = faQuestionCircle; - file: File; - fileName: string; invalidUnitTableMessage?: string; - removeSlidesCommaSeparatedKeyPhrases: string; + //Comma-seperated keyphrases used to detect slides to be removed + keyphrases: string; + private search = new Subject(); + removedSlidesNumbers: number[]; + + file: File; + filename: string; + //time until the file gets uploaded again. Must be less or equal than minutesUntilDeletion in AttachmentUnitResource.java + readonly MINUTES_UNTIL_DELETION = 29; constructor( private activatedRoute: ActivatedRoute, @@ -56,8 +61,7 @@ export class AttachmentUnitsComponent implements OnInit { private alertService: AlertService, private translateService: TranslateService, ) { - this.file = this.router.getCurrentNavigation()!.extras.state!.file; - this.fileName = this.router.getCurrentNavigation()!.extras.state!.fileName; + this.file = this.router.getCurrentNavigation()?.extras?.state?.file; const lectureRoute = this.activatedRoute.parent!.parent!; combineLatest([lectureRoute.paramMap, lectureRoute.parent!.paramMap]).subscribe(([params]) => { this.lectureId = Number(params.get('lectureId')); @@ -65,62 +69,121 @@ export class AttachmentUnitsComponent implements OnInit { }); } + /** + * Life cycle hook called by Angular to indicate that Angular is done creating the component + */ ngOnInit(): void { - this.removeSlidesCommaSeparatedKeyPhrases = ''; + this.keyphrases = ''; + this.removedSlidesNumbers = []; this.isLoading = true; this.isProcessingMode = true; - const formData: FormData = new FormData(); - formData.append('file', this.file); + if (!this.file) { + this.alertService.error(this.translateService.instant(`artemisApp.attachmentUnit.createAttachmentUnits.noFile`)); + this.isLoading = true; + return; + } - this.attachmentUnitService.getSplitUnitsData(this.lectureId, formData).subscribe({ - next: (res: any) => { - if (res) { - this.units = res.body.units; - this.numberOfPages = res.body.numberOfPages; - this.isLoading = false; - } + //regularly re-upload the file when it gets deleted in the backend + setTimeout( + () => { + this.attachmentUnitService + .uploadSlidesForProcessing(this.lectureId, this.file) + .pipe(repeat({ delay: 1000 * 60 * this.MINUTES_UNTIL_DELETION })) + .subscribe({ + next: (res) => { + this.filename = res.body!; + }, + error: (res: HttpErrorResponse) => { + onError(this.alertService, res); + this.isLoading = false; + }, + }); }, - error: (res: HttpErrorResponse) => { - if (res.error.params === 'file' && res?.error?.title) { - this.alertService.error(res.error.title); - } else { + 1000 * 60 * this.MINUTES_UNTIL_DELETION, + ); + + this.attachmentUnitService + .uploadSlidesForProcessing(this.lectureId, this.file) + .pipe( + switchMap((res) => { + if (res instanceof HttpErrorResponse) { + throw new Error(res.message); + } else { + this.filename = res.body!; + return this.attachmentUnitService.getSplitUnitsData(this.lectureId, this.filename); + } + }), + ) + .subscribe({ + next: (res) => { + this.units = res.body!.units; + this.numberOfPages = res.body!.numberOfPages; + this.isLoading = false; + }, + error: (res: HttpErrorResponse) => { onError(this.alertService, res); - } - this.isLoading = false; - }, - }); + this.isLoading = false; + }, + }); + + this.search + .pipe( + debounceTime(500), + switchMap(() => { + return this.attachmentUnitService.getSlidesToRemove(this.lectureId, this.filename, this.keyphrases); + }), + ) + .subscribe({ + next: (res) => { + if (res.body) { + this.removedSlidesNumbers = res.body.map((n) => n + 1); + } + }, + error: (res: HttpErrorResponse) => { + onError(this.alertService, res); + }, + }); } + /** + * Creates the attachment units with the information given on this page + */ createAttachmentUnits(): void { if (this.validUnitInformation()) { this.isLoading = true; - const lectureUnitInformationDTOObj: LectureUnitInformationDTO = { + const lectureUnitInformation: LectureUnitInformationDTO = { units: this.units, numberOfPages: this.numberOfPages, - removeSlidesCommaSeparatedKeyPhrases: this.removeSlidesCommaSeparatedKeyPhrases, + removeSlidesCommaSeparatedKeyPhrases: this.keyphrases, }; - const formData: FormData = new FormData(); - formData.append('file', this.file); - formData.append('lectureUnitInformationDTO', objectToJsonBlob(lectureUnitInformationDTOObj)); - this.attachmentUnitService.createUnits(this.lectureId, formData).subscribe({ + this.attachmentUnitService.createUnits(this.lectureId, this.filename, lectureUnitInformation).subscribe({ next: () => { this.router.navigate(['../../'], { relativeTo: this.activatedRoute }); this.isLoading = false; }, error: (res: HttpErrorResponse) => { - if (res.error.params === 'file' && res?.error?.title) { - this.alertService.error(res.error.title); - } else { - onError(this.alertService, res); - } - this.isLoading = false; + onError(this.alertService, res); }, }); } } + set searchTerm(searchTerm: string) { + //only consider non-empty searches for slide removal + if (searchTerm.trim() !== '') { + this.keyphrases = searchTerm; + this.search.next(); + } else { + this.removedSlidesNumbers = []; + } + } + + get searchTerm(): string { + return this.keyphrases; + } + /** * Go back to the lecture page */ diff --git a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/attachmentUnit.service.ts b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/attachmentUnit.service.ts index 93a59271a933..e2e3372e6190 100644 --- a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/attachmentUnit.service.ts +++ b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/attachmentUnit.service.ts @@ -1,9 +1,10 @@ import { LectureUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/lectureUnit.service'; import { AttachmentUnit } from 'app/entities/lecture-unit/attachmentUnit.model'; -import { HttpClient, HttpResponse } from '@angular/common/http'; +import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +import { LectureUnitInformationDTO } from 'app/lecture/lecture-unit/lecture-unit-management/attachment-units/attachment-units.component'; type EntityResponseType = HttpResponse; @@ -41,11 +42,22 @@ export class AttachmentUnitService { .pipe(map((res: EntityResponseType) => this.lectureUnitService.convertLectureUnitResponseDatesFromServer(res))); } - getSplitUnitsData(lectureId: number, formData: FormData) { - return this.httpClient.post(`${this.resourceURL}/lectures/${lectureId}/process-units`, formData, { observe: 'response' }); + getSplitUnitsData(lectureId: number, filename: string) { + return this.httpClient.get(`${this.resourceURL}/lectures/${lectureId}/attachment-units/data/${filename}`, { observe: 'response' }); } - createUnits(lectureId: number, formData: FormData) { - return this.httpClient.post(`${this.resourceURL}/lectures/${lectureId}/attachment-units/split`, formData, { observe: 'response' }); + createUnits(lectureId: number, filename: string, lectureUnitInformation: LectureUnitInformationDTO) { + return this.httpClient.post(`${this.resourceURL}/lectures/${lectureId}/attachment-units/split/${filename}`, lectureUnitInformation, { observe: 'response' }); + } + + uploadSlidesForProcessing(lectureId: number, file: File) { + const formData: FormData = new FormData(); + formData.append('file', file); + return this.httpClient.post(`${this.resourceURL}/lectures/${lectureId}/attachment-units/upload`, formData, { observe: 'response' }); + } + + getSlidesToRemove(lectureId: number, filename: string, keyPhrases: string) { + const params = new HttpParams().set('commaSeparatedKeyPhrases', keyPhrases); + return this.httpClient.get>(`${this.resourceURL}/lectures/${lectureId}/attachment-units/slides-to-remove/${filename}`, { params, observe: 'response' }); } } diff --git a/src/main/webapp/i18n/de/lectureUnit.json b/src/main/webapp/i18n/de/lectureUnit.json index 8da5216f9966..0b9be573709f 100644 --- a/src/main/webapp/i18n/de/lectureUnit.json +++ b/src/main/webapp/i18n/de/lectureUnit.json @@ -120,13 +120,19 @@ "secondLine": "Artemis erwartet eine Wiederholung der Gliederungsfolie vor jeder Einheit.", "note": "Hinweis: Du kannst die Informationen bearbeiten, bevor die angehängten Einheiten übermittelt werden." }, + "noFile": "Es gibt keine Datei für die automatische Einheitenverarbeitung. Bitte gehen Sie einen Schritt zurück und versuchen Sie es erneut.", "noUnitDetected": "Es wurde keine Einheit erkannt und konnte nicht automatisch geteilt werden. Bitte füge Informationen für jede Einheit hinzu!", "pageTitle": "Einheiten", "addUnit": "Einheit hinzufügen", "processUnits": "Einheiten festlegen", "processAutomatically": "Automatische Einheitenverarbeitung", "removeSlides": "Entferne Folien", - "removeSlidesInfo": "Alle Folien, die einen der folgenden Schlüsselsätze enthalten, werden entfernt. Mehrere Schlüsselsätze werden mit einem Komma getrennt; zwischen Groß- und Kleinbuchstaben wird nicht unterschieden. Ein Beispiel: 'Pause, Beispiellösungen'", + "removeSlidesInfo": { + "firstLine": "Alle Folien, die einen der folgenden Schlüsselsätze enthalten, werden entfernt.", + "secondLine": "Mehrere Schlüsselsätze werden mit einem Komma getrennt, zwischen Groß- und Kleinbuchstaben wird nicht unterschieden.", + "thirdLine": "Folgende Folien werden entfernt:" + }, + "timeout": "Zeitüberschreitung während der automatischen Einheitenverarbeitung. Bitte versuchen Sie es erneut.", "removeSlidesPlaceholder": "Beispiel: 'Pause, Beispiellösungen'", "validation": { "invalidPages": "Die Startseite oder die letzte Seite von {{ unitName }} sind falsch eingestellt.", diff --git a/src/main/webapp/i18n/en/lectureUnit.json b/src/main/webapp/i18n/en/lectureUnit.json index 757b04c17c87..477ddfa2cb4b 100644 --- a/src/main/webapp/i18n/en/lectureUnit.json +++ b/src/main/webapp/i18n/en/lectureUnit.json @@ -121,6 +121,7 @@ "secondLine": "Artemis expects a repetition of the Outline slide at the start of every unit.", "note": "Note: You will have the option to edit the information before finally submitting the attachment units." }, + "noFile": "There is no file for the automatic unit processing. Please go back and try again.", "noUnitDetected": "No unit was detected and could not split automatically. Please add information for each unit!", "pageTitle": "Process units", "addUnit": "Add unit", @@ -128,7 +129,12 @@ "processUnits": "Process units", "processAutomatically": "Automatic unit processing", "removeSlides": "Remove slides", - "removeSlidesInfo": "All slides that contain any of the following key phrases will be removed from all units. Multiple key phrases are comma-separated; key phrases are not case-sensitive. For example: 'Break, Solution slides'", + "removeSlidesInfo": { + "firstLine": "All slides that contain any of the following key phrases will be removed from all units.", + "secondLine": "Multiple key phrases are comma-separated, key phrases are not case-sensitive.", + "thirdLine": "The following slides will be removed:" + }, + "timeout": "Timed out during automatic unit processing. Please try again.", "removeSlidesPlaceholder": "Example: 'Break, Solution slides'", "validation": { "invalidPages": "The start page or end page of {{ unitName }} are set incorrectly.", diff --git a/src/test/java/de/tum/in/www1/artemis/lecture/AttachmentUnitsIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/lecture/AttachmentUnitsIntegrationTest.java index 16ee82a1dbe2..6017eac5973c 100644 --- a/src/test/java/de/tum/in/www1/artemis/lecture/AttachmentUnitsIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/lecture/AttachmentUnitsIntegrationTest.java @@ -1,9 +1,12 @@ package de.tum.in.www1.artemis.lecture; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -15,16 +18,21 @@ import org.apache.pdfbox.pdmodel.font.Standard14Fonts; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import de.tum.in.www1.artemis.AbstractSpringIntegrationIndependentTest; import de.tum.in.www1.artemis.domain.Lecture; import de.tum.in.www1.artemis.domain.lecture.AttachmentUnit; import de.tum.in.www1.artemis.repository.AttachmentUnitRepository; import de.tum.in.www1.artemis.repository.SlideRepository; +import de.tum.in.www1.artemis.service.LectureUnitProcessingService; import de.tum.in.www1.artemis.user.UserUtilService; import de.tum.in.www1.artemis.util.RequestUtilService; import de.tum.in.www1.artemis.web.rest.dto.LectureUnitInformationDTO; @@ -49,14 +57,20 @@ class AttachmentUnitsIntegrationTest extends AbstractSpringIntegrationIndependen @Autowired private LectureUtilService lectureUtilService; + @Autowired + private LectureUnitProcessingService lectureUnitProcessingService; + private LectureUnitInformationDTO lectureUnitSplits; private Lecture lecture1; + private Lecture invalidLecture; + @BeforeEach void initTestCase() { userUtilService.addUsers(TEST_PREFIX, 1, 1, 0, 1); this.lecture1 = lectureUtilService.createCourseWithLecture(true); + this.invalidLecture = lectureUtilService.createLecture(null, null); List units = new ArrayList<>(); this.lectureUnitSplits = new LectureUnitInformationDTO(units, 1, "Break"); // Add users that are not in the course @@ -87,80 +101,164 @@ void testAll_InstructorNotInCourse_shouldReturnForbidden() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void splitLectureFile_asInstructor_shouldGetUnitsInformation() throws Exception { - var filePart = createLectureFile(true); + void testAll_LectureWithoutCourse_shouldReturnConflict() throws Exception { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("commaSeparatedKeyPhrases", "Break, Example Solution"); + + request.postWithMultipartFile("/api/lectures/" + invalidLecture.getId() + "/attachment-units/upload", null, "upload", createLectureFile(true), String.class, + HttpStatus.CONFLICT); + request.get("/api/lectures/" + invalidLecture.getId() + "/attachment-units/data/any-file", HttpStatus.CONFLICT, LectureUnitInformationDTO.class); + request.get("/api/lectures/" + invalidLecture.getId() + "/attachment-units/slides-to-remove/any-file", HttpStatus.CONFLICT, LectureUnitInformationDTO.class, params); + request.postListWithResponseBody("/api/lectures/" + invalidLecture.getId() + "/attachment-units/split/any-file", lectureUnitSplits, AttachmentUnit.class, + HttpStatus.CONFLICT); + } - LectureUnitInformationDTO lectureUnitSplitInfo = request.postWithMultipartFile("/api/lectures/" + lecture1.getId() + "/process-units", null, "process-units", filePart, - LectureUnitInformationDTO.class, HttpStatus.OK); + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testAll_WrongLecture_shouldReturnNotFound() throws Exception { + // Tests that files created for another lecture are not accessible + // even by instructors of other lectures + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("commaSeparatedKeyPhrases", "Break, Example Solution"); + var lectureFile = createLectureFile(true); + String filename = manualFileUpload(invalidLecture.getId(), lectureFile); + Path filePath = lectureUnitProcessingService.getPathForTempFilename(invalidLecture.getId(), filename); + + request.get("/api/lectures/" + lecture1.getId() + "/attachment-units/data/" + filename, HttpStatus.NOT_FOUND, LectureUnitInformationDTO.class); + request.get("/api/lectures/" + lecture1.getId() + "/attachment-units/slides-to-remove/" + filename, HttpStatus.NOT_FOUND, LectureUnitInformationDTO.class, params); + request.postListWithResponseBody("/api/lectures/" + lecture1.getId() + "/attachment-units/split/" + filename, lectureUnitSplits, AttachmentUnit.class, + HttpStatus.NOT_FOUND); + assertThat(Files.exists(filePath)).isTrue(); + } - assertThat(lectureUnitSplitInfo.units()).hasSize(2); - assertThat(lectureUnitSplitInfo.numberOfPages()).isEqualTo(20); + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testAll_IOException_ShouldReturnInternalServerError() throws Exception { + var lectureFile = createLectureFile(true); + String filename = manualFileUpload(lecture1.getId(), lectureFile); + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("commaSeparatedKeyPhrases", ""); + + try (MockedStatic mockedFiles = Mockito.mockStatic(Files.class)) { + mockedFiles.when(() -> Files.readAllBytes(any())).thenThrow(IOException.class); + mockedFiles.when(() -> Files.exists(any())).thenReturn(true); + request.get("/api/lectures/" + lecture1.getId() + "/attachment-units/data/" + filename, HttpStatus.INTERNAL_SERVER_ERROR, LectureUnitInformationDTO.class); + request.getList("/api/lectures/" + lecture1.getId() + "/attachment-units/slides-to-remove/" + filename, HttpStatus.INTERNAL_SERVER_ERROR, Integer.class, params); + request.postListWithResponseBody("/api/lectures/" + lecture1.getId() + "/attachment-units/split/" + filename, lectureUnitSplits, AttachmentUnit.class, + HttpStatus.INTERNAL_SERVER_ERROR); + } } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void splitLectureFile_asInstructor_shouldCreateAttachmentUnits() throws Exception { + void uploadSlidesForProcessing_asInstructor_shouldGetFilename() throws Exception { var filePart = createLectureFile(true); - LectureUnitInformationDTO lectureUnitSplitInfo = request.postWithMultipartFile("/api/lectures/" + lecture1.getId() + "/process-units", null, "process-units", filePart, - LectureUnitInformationDTO.class, HttpStatus.OK); + String uploadInfo = request.postWithMultipartFile("/api/lectures/" + lecture1.getId() + "/attachment-units/upload", null, "upload", filePart, String.class, HttpStatus.OK); + assertThat(uploadInfo).contains(".pdf"); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void uploadSlidesForProcessing_asInstructor_shouldThrowError() throws Exception { + var filePartWord = createLectureFile(false); + request.postWithMultipartFile("/api/lectures/" + lecture1.getId() + "/attachment-units/upload", null, "upload", filePartWord, LectureUnitInformationDTO.class, + HttpStatus.BAD_REQUEST); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void getAttachmentUnitsData_asInstructor_shouldGetUnitsInformation() throws Exception { + var lectureFile = createLectureFile(true); + String filename = manualFileUpload(lecture1.getId(), lectureFile); + + LectureUnitInformationDTO lectureUnitSplitInfo = request.get("/api/lectures/" + lecture1.getId() + "/attachment-units/data/" + filename, HttpStatus.OK, + LectureUnitInformationDTO.class); assertThat(lectureUnitSplitInfo.units()).hasSize(2); assertThat(lectureUnitSplitInfo.numberOfPages()).isEqualTo(20); + } - lectureUnitSplitInfo = new LectureUnitInformationDTO(lectureUnitSplitInfo.units(), lectureUnitSplitInfo.numberOfPages(), ""); + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void getAttachmentUnitsData_asInstructor_shouldThrowError() throws Exception { + var lectureFile = createLectureFile(false); + String filename = manualFileUpload(lecture1.getId(), lectureFile); - List attachmentUnits = List.of(request.postWithMultipartFile("/api/lectures/" + lecture1.getId() + "/attachment-units/split", lectureUnitSplitInfo, - "lectureUnitInformationDTO", filePart, AttachmentUnit[].class, HttpStatus.OK)); + request.get("/api/lectures/" + lecture1.getId() + "/attachment-units/data/" + filename, HttpStatus.BAD_REQUEST, LectureUnitInformationDTO.class); + request.get("/api/lectures/" + lecture1.getId() + "/attachment-units/data/non-existent-file", HttpStatus.NOT_FOUND, LectureUnitInformationDTO.class); + } - assertThat(attachmentUnits).hasSize(2); - assertThat(slideRepository.findAll()).hasSize(20); // 20 slides should be created for 2 attachment units + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void getSlidesToRemove_asInstructor_shouldGetUnitsInformation() throws Exception { + var lectureFile = createLectureFile(true); + String filename = manualFileUpload(lecture1.getId(), lectureFile); + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("commaSeparatedKeyPhrases", "Break, Example Solution"); - List attachmentUnitIds = attachmentUnits.stream().map(AttachmentUnit::getId).toList(); - List attachmentUnitList = attachmentUnitRepository.findAllById(attachmentUnitIds); + List removedSlides = request.getList("/api/lectures/" + lecture1.getId() + "/attachment-units/slides-to-remove/" + filename, HttpStatus.OK, Integer.class, params); - assertThat(attachmentUnitList).hasSize(2); - assertThat(attachmentUnitList).isEqualTo(attachmentUnits); + assertThat(removedSlides).hasSize(2); + // index is one lower than in createLectureFile because the loop starts at 1. + assertThat(removedSlides).contains(5, 6); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void splitLectureFile_asInstructor_shouldThrowError() throws Exception { - var filePartWord = createLectureFile(false); - // if trying to process not the right pdf file then it should throw server error - request.postWithMultipartFile("/api/lectures/" + lecture1.getId() + "/process-units", null, "process-units", filePartWord, LectureUnitInformationDTO.class, - HttpStatus.INTERNAL_SERVER_ERROR); + void getSlidesToRemove_asInstructor_shouldThrowError() throws Exception { + var lectureFile = createLectureFile(false); + String filename = manualFileUpload(lecture1.getId(), lectureFile); + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("commaSeparatedKeyPhrases", "Break, Example Solution"); + + request.get("/api/lectures/" + lecture1.getId() + "/attachment-units/slides-to-remove/" + filename, HttpStatus.BAD_REQUEST, LectureUnitInformationDTO.class, params); + request.get("/api/lectures/" + lecture1.getId() + "/attachment-units/slides-to-remove/non-existent-file", HttpStatus.NOT_FOUND, LectureUnitInformationDTO.class, params); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void splitLectureFile_asInstructor_createAttachmentUnits_shouldThrowError() throws Exception { - var filePartPDF = createLectureFile(true); - var filePartWord = createLectureFile(false); + void createAttachmentUnits_asInstructor_shouldCreateAttachmentUnits() throws Exception { + var lectureFile = createLectureFile(true); + String filename = manualFileUpload(lecture1.getId(), lectureFile); + + LectureUnitInformationDTO lectureUnitSplitInfo = request.get("/api/lectures/" + lecture1.getId() + "/attachment-units/data/" + filename, HttpStatus.OK, + LectureUnitInformationDTO.class); - LectureUnitInformationDTO lectureUnitSplitInfo = request.postWithMultipartFile("/api/lectures/" + lecture1.getId() + "/process-units", null, "process-units", filePartPDF, - LectureUnitInformationDTO.class, HttpStatus.OK); + assertThat(lectureUnitSplitInfo.units()).hasSize(2); + assertThat(lectureUnitSplitInfo.numberOfPages()).isEqualTo(20); + + lectureUnitSplitInfo = new LectureUnitInformationDTO(lectureUnitSplitInfo.units(), lectureUnitSplitInfo.numberOfPages(), ""); + + List attachmentUnits = request.postListWithResponseBody("/api/lectures/" + lecture1.getId() + "/attachment-units/split/" + filename, lectureUnitSplitInfo, + AttachmentUnit.class, HttpStatus.OK); + + assertThat(attachmentUnits).hasSize(2); + assertThat(slideRepository.findAll()).hasSize(20); // 20 slides should be created for 2 attachment units - // if trying to create multiple units with not the right pdf file then it should throw error - request.postWithMultipartFile("/api/lectures/" + lecture1.getId() + "/attachment-units/split", lectureUnitSplitInfo, "lectureUnitInformationDTO", filePartWord, - AttachmentUnit[].class, HttpStatus.BAD_REQUEST); + List attachmentUnitIds = attachmentUnits.stream().map(AttachmentUnit::getId).toList(); + List attachmentUnitList = attachmentUnitRepository.findAllById(attachmentUnitIds); + + assertThat(attachmentUnitList).hasSize(2); + assertThat(attachmentUnitList).isEqualTo(attachmentUnits); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void splitLectureFile_asInstructor_shouldRemoveSolutionSlides_and_removeBreakSlides() throws Exception { - var filePart = createLectureFile(true); + void createAttachmentUnits_asInstructor_shouldRemoveSlides() throws Exception { + var lectureFile = createLectureFile(true); + String filename = manualFileUpload(lecture1.getId(), lectureFile); - LectureUnitInformationDTO lectureUnitSplitInfo = request.postWithMultipartFile("/api/lectures/" + lecture1.getId() + "/process-units", null, "process-units", filePart, - LectureUnitInformationDTO.class, HttpStatus.OK); + LectureUnitInformationDTO lectureUnitSplitInfo = request.get("/api/lectures/" + lecture1.getId() + "/attachment-units/data/" + filename, HttpStatus.OK, + LectureUnitInformationDTO.class); assertThat(lectureUnitSplitInfo.units()).hasSize(2); assertThat(lectureUnitSplitInfo.numberOfPages()).isEqualTo(20); var commaSeparatedKeyPhrases = String.join(",", new String[] { "Break", "Example solution" }); lectureUnitSplitInfo = new LectureUnitInformationDTO(lectureUnitSplitInfo.units(), lectureUnitSplitInfo.numberOfPages(), commaSeparatedKeyPhrases); - List attachmentUnits = List.of(request.postWithMultipartFile("/api/lectures/" + lecture1.getId() + "/attachment-units/split", lectureUnitSplitInfo, - "lectureUnitInformationDTO", filePart, AttachmentUnit[].class, HttpStatus.OK)); + List attachmentUnits = request.postListWithResponseBody("/api/lectures/" + lecture1.getId() + "/attachment-units/split/" + filename, lectureUnitSplitInfo, + AttachmentUnit.class, HttpStatus.OK); assertThat(attachmentUnits).hasSize(2); assertThat(slideRepository.findAll()).hasSize(18); // 18 slides should be created for 2 attachment units (1 break slide is removed and 1 solution slide is removed) @@ -189,11 +287,27 @@ void splitLectureFile_asInstructor_shouldRemoveSolutionSlides_and_removeBreakSli } } + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void createAttachmentUnits_asInstructor_shouldThrowError() throws Exception { + var lectureFile = createLectureFile(false); + String filename = manualFileUpload(lecture1.getId(), lectureFile); + + request.postListWithResponseBody("/api/lectures/" + lecture1.getId() + "/attachment-units/split/" + filename, lectureUnitSplits, AttachmentUnit.class, + HttpStatus.BAD_REQUEST); + request.postListWithResponseBody("/api/lectures/" + lecture1.getId() + "/attachment-units/split/non-existent-file", lectureUnitSplits, AttachmentUnit.class, + HttpStatus.NOT_FOUND); + } + private void testAllPreAuthorize() throws Exception { - request.postWithMultipartFile("/api/lectures/" + lecture1.getId() + "/process-units", null, "process-units", createLectureFile(true), LectureUnitInformationDTO.class, + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("commaSeparatedKeyPhrases", ""); + + request.postWithMultipartFile("/api/lectures/" + lecture1.getId() + "/attachment-units/upload", null, "upload", createLectureFile(true), String.class, HttpStatus.FORBIDDEN); - request.postWithMultipartFile("/api/lectures/" + lecture1.getId() + "/attachment-units/split", lectureUnitSplits, "lectureUnitInformationDTO", createLectureFile(true), - AttachmentUnit[].class, HttpStatus.FORBIDDEN); + request.get("/api/lectures/" + lecture1.getId() + "/attachment-units/data/any-file", HttpStatus.FORBIDDEN, LectureUnitInformationDTO.class); + request.get("/api/lectures/" + lecture1.getId() + "/attachment-units/slides-to-remove/any-file", HttpStatus.FORBIDDEN, LectureUnitInformationDTO.class, params); + request.postListWithResponseBody("/api/lectures/" + lecture1.getId() + "/attachment-units/split/any-file", lectureUnitSplits, AttachmentUnit.class, HttpStatus.FORBIDDEN); } /** @@ -273,4 +387,13 @@ private MockMultipartFile createLectureFile(boolean shouldBePDF) throws IOExcept } } + /** + * Uploads a lecture file. Needed to test some errors (wrong filetype) and to keep test cases independent. + * + * @param file the file to be uploaded + * @return String filename in the temp folder + */ + private String manualFileUpload(long lectureId, MockMultipartFile file) throws IOException { + return lectureUnitProcessingService.saveTempFileForProcessing(lectureId, file, 10); + } } diff --git a/src/test/javascript/spec/component/lecture-unit/attachment-unit/attachment-unit.service.spec.ts b/src/test/javascript/spec/component/lecture-unit/attachment-unit/attachment-unit.service.spec.ts index 2a0fbfd19395..765d4e646a7e 100644 --- a/src/test/javascript/spec/component/lecture-unit/attachment-unit/attachment-unit.service.spec.ts +++ b/src/test/javascript/spec/component/lecture-unit/attachment-unit/attachment-unit.service.spec.ts @@ -10,6 +10,7 @@ import { AttachmentUnit } from 'app/entities/lecture-unit/attachmentUnit.model'; import { Attachment, AttachmentType } from 'app/entities/attachment.model'; import { AttachmentUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/attachmentUnit.service'; import { objectToJsonBlob } from 'app/utils/blob-util'; +import { LectureUnitInformationDTO } from 'app/lecture/lecture-unit/lecture-unit-management/attachment-units/attachment-units.component'; describe('AttachmentUnitService', () => { let service: AttachmentUnitService; @@ -121,22 +122,22 @@ describe('AttachmentUnitService', () => { const returnedFromService = { ...returnedAttachmentUnits }; const expected = { ...returnedFromService }; - const file = new File([''], 'testFile.pdf', { type: 'application/pdf' }); - const formData: FormData = new FormData(); - formData.append('file', file); - formData.append( - 'lectureUnitSplitDTOs', - objectToJsonBlob([ + const filename = 'filename-on-server'; + const lectureUnitInformation: LectureUnitInformationDTO = { + units: [ { unitName: 'Unit 1', releaseDate: dayjs().year(2022).month(3).date(5), startPage: 1, endPage: 20, }, - ]), - ); + ], + numberOfPages: 0, + removeSlidesCommaSeparatedKeyPhrases: '', + }; + service - .createUnits(1, formData) + .createUnits(1, filename, lectureUnitInformation) .pipe(take(1)) .subscribe((resp) => (response = resp)); const req = httpMock.expectOne({ method: 'POST' }); @@ -157,16 +158,45 @@ describe('AttachmentUnitService', () => { const expected = { ...returnedFromService }; + const filename = 'filename-on-server'; + service + .getSplitUnitsData(1, filename) + .pipe(take(1)) + .subscribe((resp) => (response = resp)); + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(returnedFromService); + expect(response.body).toEqual(expected); + })); + + it('should get removed slides data', fakeAsync(() => { + let response: any; + const returnedFromService = [1, 2, 3]; + const expected = [...returnedFromService]; + + const keyphrases = 'phrase1, phrase2'; + const filename = 'filename-on-server'; + service + .getSlidesToRemove(1, filename, keyphrases) + .pipe(take(1)) + .subscribe((resp) => (response = resp)); + const req = httpMock.expectOne({ method: 'GET' }); + req.flush(returnedFromService); + expect(response.body).toEqual(expected); + })); + + it('should upload slides', fakeAsync(() => { + let response: any; + const returnedFromService = 'filename-on-server'; + const expected = 'filename-on-server'; const file = new File([''], 'testFile.pdf', { type: 'application/pdf' }); - const formData: FormData = new FormData(); - formData.append('file', file); service - .getSplitUnitsData(1, formData) + .uploadSlidesForProcessing(1, file) .pipe(take(1)) .subscribe((resp) => (response = resp)); const req = httpMock.expectOne({ method: 'POST' }); req.flush(returnedFromService); + expect(response.body).toEqual(expected); })); }); diff --git a/src/test/javascript/spec/component/lecture-unit/attachment-unit/attachment-units.component.spec.ts b/src/test/javascript/spec/component/lecture-unit/attachment-unit/attachment-units.component.spec.ts index d95cd8a60ce9..20e0b0559a1c 100644 --- a/src/test/javascript/spec/component/lecture-unit/attachment-unit/attachment-units.component.spec.ts +++ b/src/test/javascript/spec/component/lecture-unit/attachment-unit/attachment-units.component.spec.ts @@ -1,12 +1,11 @@ import { Component, Input } from '@angular/core'; -import { AttachmentUnitsComponent } from 'app/lecture/lecture-unit/lecture-unit-management/attachment-units/attachment-units.component'; +import { AttachmentUnitsComponent, LectureUnitInformationDTO } from 'app/lecture/lecture-unit/lecture-unit-management/attachment-units/attachment-units.component'; import { FormDateTimePickerComponent } from 'app/shared/date-time-picker/date-time-picker.component'; import { TranslateDirective } from 'app/shared/language/translate.directive'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; -import { ComponentFixture, TestBed, fakeAsync } from '@angular/core/testing'; +import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, tick } from '@angular/core/testing'; import { AlertService } from 'app/core/util/alert.service'; import { ActivatedRoute, Router } from '@angular/router'; -import { objectToJsonBlob } from 'app/utils/blob-util'; import { of } from 'rxjs'; import { AttachmentUnitService } from 'app/lecture/lecture-unit/lecture-unit-management/attachmentUnit.service'; import { MockRouterLinkDirective } from '../../../helpers/mocks/directive/mock-router-link.directive'; @@ -65,7 +64,6 @@ describe('AttachmentUnitsComponent', () => { }; const units = [unit1, unit2, unit3]; const numberOfPages = 60; - const removeBreakSlides = true; beforeEach(() => { TestBed.configureTestingModule({ @@ -135,15 +133,13 @@ describe('AttachmentUnitsComponent', () => { }); it('should initialize with remove slides key phrases empty', () => { - expect(attachmentUnitsComponent.removeSlidesCommaSeparatedKeyPhrases).toMatch(''); + expect(attachmentUnitsComponent.keyphrases).toMatch(''); }); it('should create attachment units', fakeAsync(() => { - const lectureUnitInformationDTOObj = { units: units, numberOfPages: numberOfPages, removeBreakSlides: removeBreakSlides }; - const file = new File([''], 'testFile.pdf', { type: 'application/pdf' }); - const formData: FormData = new FormData(); - formData.append('file', file); - formData.append('lectureUnitInformationDTO', objectToJsonBlob(lectureUnitInformationDTOObj)); + const lectureUnitInformation: LectureUnitInformationDTO = { units: units, numberOfPages: numberOfPages, removeSlidesCommaSeparatedKeyPhrases: '' }; + const filename = 'filename-on-server'; + attachmentUnitsComponent.filename = filename; const responseBody: AttachmentUnitsResponseType = { units, @@ -159,8 +155,7 @@ describe('AttachmentUnitsComponent', () => { attachmentUnitsComponent.createAttachmentUnits(); attachmentUnitsComponentFixture.detectChanges(); - - expect(createAttachmentUnitStub).toHaveBeenCalledWith(1, formData); + expect(createAttachmentUnitStub).toHaveBeenCalledWith(1, filename, lectureUnitInformation); expect(createAttachmentUnitStub).toHaveBeenCalledOnce(); expect(navigateSpy).toHaveBeenCalledOnce(); })); @@ -223,7 +218,55 @@ describe('AttachmentUnitsComponent', () => { const previousState = jest.spyOn(attachmentUnitsComponent, 'cancelSplit'); attachmentUnitsComponent.cancelSplit(); expect(previousState).toHaveBeenCalledOnce(); - expect(navigateSpy).toHaveBeenCalledOnce(); })); + + it('should get slides to remove', fakeAsync(() => { + const expectedSlideIndexes = [1, 2, 3]; + // slide indexes are increased by 1 for display in the frontend + const expectedSlideNumbers = expectedSlideIndexes.map((n) => n + 1); + const expectedResponse: HttpResponse> = new HttpResponse({ + body: expectedSlideIndexes, + status: 200, + }); + attachmentUnitsComponent.searchTerm = 'key, phrases'; + const getSlidesToRemoveSpy = jest.spyOn(attachmentUnitService, 'getSlidesToRemove').mockReturnValue(of(expectedResponse)); + tick(1000); + expect(getSlidesToRemoveSpy).toHaveBeenCalledOnce(); + expect(attachmentUnitsComponent.removedSlidesNumbers).toEqual(expectedSlideNumbers); + })); + + it('should not get slides to remove if query is empty', fakeAsync(() => { + attachmentUnitsComponent.removedSlidesNumbers = [1, 2, 3]; + attachmentUnitsComponent.searchTerm = ''; + const getSlidesToRemoveSpy = jest.spyOn(attachmentUnitService, 'getSlidesToRemove'); + tick(1000); + expect(getSlidesToRemoveSpy).not.toHaveBeenCalled(); + expect(attachmentUnitsComponent.removedSlidesNumbers).toBeEmpty(); + })); + + it('should start uploading file again after timeout', fakeAsync(() => { + const response1: HttpResponse = new HttpResponse({ + body: 'filename-on-server', + status: 200, + }); + const response2: HttpResponse = new HttpResponse({ + body: { + units: [], + numberOfPages: 1, + removeSlidesCommaSeparatedKeyPhrases: '', + }, + status: 200, + }); + + const uploadSlidesSpy = jest.spyOn(attachmentUnitService, 'uploadSlidesForProcessing').mockReturnValue(of(response1)); + attachmentUnitService.getSplitUnitsData = jest.fn().mockReturnValue(of(response2)); + attachmentUnitsComponent.ngOnInit(); + attachmentUnitsComponentFixture.detectChanges(); + + expect(uploadSlidesSpy).toHaveBeenCalledOnce(); + tick(1000 * 60 * attachmentUnitsComponent.MINUTES_UNTIL_DELETION); + expect(uploadSlidesSpy).toHaveBeenCalledTimes(2); + discardPeriodicTasks(); + })); }); diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-attachment-units.service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-attachment-units.service.ts index 65b599613c83..77e594efa6ce 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-attachment-units.service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-attachment-units.service.ts @@ -1,8 +1,12 @@ -import { Observable, of } from 'rxjs'; -import { HttpHeaders, HttpResponse } from '@angular/common/http'; +import { of } from 'rxjs'; +import { LectureUnitInformationDTO } from 'app/lecture/lecture-unit/lecture-unit-management/attachment-units/attachment-units.component'; export class MockAttachmentUnitsService { - getSplitUnitsData = (lectureId: number, formData: FormData) => of({}); + getSplitUnitsData = (lectureId: number, filename: string) => of({}); - createUnits = (lectureId: number, formData: FormData) => of({}); + createUnits = (lectureId: number, filename: string, lectureUnitInformation: LectureUnitInformationDTO) => of({}); + + uploadSlidesForProcessing = (lectureId: number, file: File) => of({}); + + getSlidesToRemove = (lectureId: number, filename: string, keyPhrases: string) => of({}); }