diff --git a/.github/workflows/check-translation-keys.yml b/.github/workflows/check-translation-keys.yml index a14ed3f43b9a..02dbe17f2955 100644 --- a/.github/workflows/check-translation-keys.yml +++ b/.github/workflows/check-translation-keys.yml @@ -18,7 +18,4 @@ jobs: with: python-version: "3.12" - name: Check if translation keys match - run: > - python .ci/translation-file-checker/translation_file_checker.py - --german-files src/main/webapp/i18n/de/ - --english-files src/main/webapp/i18n/en/ + run: python .ci/translation-file-checker/translation_file_checker.py --german-files src/main/webapp/i18n/de/ --english-files src/main/webapp/i18n/en/ diff --git a/.idea/runConfigurations/_template__of_Gradle.xml b/.idea/runConfigurations/_template__of_Gradle.xml index 0d7f0a89449e..7c6cecee1dad 100644 --- a/.idea/runConfigurations/_template__of_Gradle.xml +++ b/.idea/runConfigurations/_template__of_Gradle.xml @@ -4,7 +4,7 @@ diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyImportOptionsDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyImportOptionsDTO.java new file mode 100644 index 000000000000..7d64a43ca560 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyImportOptionsDTO.java @@ -0,0 +1,12 @@ +package de.tum.cit.aet.artemis.atlas.dto; + +import java.time.ZonedDateTime; +import java.util.Optional; +import java.util.Set; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record CompetencyImportOptionsDTO(Set competencyIds, Optional sourceCourseId, boolean importRelations, boolean importExercises, boolean importLectures, + Optional referenceDate, boolean isReleaseDate) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyRepository.java index eb2fc3eccab5..91ae85978b42 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyRepository.java @@ -26,9 +26,13 @@ public interface CompetencyRepository extends ArtemisJpaRepository findAllForCourse(@Param("courseId") long courseId); + Set findAllForCourseWithExercisesAndLectureUnitsAndLecturesAndAttachments(@Param("courseId") long courseId); @Query(""" SELECT c diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java index 9a164343bc0e..d8b66519355c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CourseCompetencyRepository.java @@ -45,6 +45,44 @@ public interface CourseCompetencyRepository extends ArtemisJpaRepository findAllForCourse(@Param("courseId") long courseId); + @Query(""" + SELECT c + FROM CourseCompetency c + LEFT JOIN FETCH c.exercises ex + LEFT JOIN FETCH c.lectureUnits lu + LEFT JOIN FETCH lu.lecture l + LEFT JOIN FETCH l.attachments + WHERE c.course.id = :courseId + """) + Set findAllForCourseWithExercisesAndLectureUnitsAndLecturesAndAttachments(@Param("courseId") long courseId); + + @Query(""" + SELECT c + FROM CourseCompetency c + LEFT JOIN FETCH c.exercises ex + LEFT JOIN FETCH c.lectureUnits lu + LEFT JOIN FETCH lu.lecture l + LEFT JOIN FETCH l.lectureUnits + LEFT JOIN FETCH l.attachments + WHERE c.id = :id + """) + Optional findByIdWithExercisesAndLectureUnitsAndLectures(@Param("id") long id); + + default CourseCompetency findByIdWithExercisesAndLectureUnitsAndLecturesElseThrow(long id) { + return getValueElseThrow(findByIdWithExercisesAndLectureUnitsAndLectures(id), id); + } + + @Query(""" + SELECT c + FROM CourseCompetency c + LEFT JOIN FETCH c.exercises ex + LEFT JOIN FETCH c.lectureUnits lu + LEFT JOIN FETCH lu.lecture l + LEFT JOIN FETCH l.attachments + WHERE c.id IN :ids + """) + Set findAllByIdWithExercisesAndLectureUnitsAndLecturesAndAttachments(@Param("ids") Set ids); + /** * Fetches all information related to the calculation of the mastery for exercises in a competency. * The complex grouping by is necessary for postgres diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/PrerequisiteRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/PrerequisiteRepository.java index a2187579a427..9616c2a5f34b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/PrerequisiteRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/PrerequisiteRepository.java @@ -19,26 +19,30 @@ public interface PrerequisiteRepository extends ArtemisJpaRepository findAllByCourseIdOrderById(long courseId); @Query(""" - SELECT c - FROM Prerequisite c - WHERE c.course.id = :courseId + SELECT p + FROM Prerequisite p + LEFT JOIN FETCH p.exercises + LEFT JOIN FETCH p.lectureUnits lu + LEFT JOIN FETCH lu.lecture l + LEFT JOIN FETCH l.attachments + WHERE p.course.id = :courseId """) - Set findAllForCourse(@Param("courseId") long courseId); + Set findAllForCourseWithExercisesAndLectureUnitsAndLecturesAndAttachments(@Param("courseId") long courseId); @Query(""" - SELECT c - FROM Prerequisite c - LEFT JOIN FETCH c.lectureUnits lu - LEFT JOIN FETCH c.exercises - WHERE c.id = :competencyId + SELECT p + FROM Prerequisite p + LEFT JOIN FETCH p.lectureUnits lu + LEFT JOIN FETCH p.exercises + WHERE p.id = :competencyId """) Optional findByIdWithLectureUnitsAndExercises(@Param("competencyId") long competencyId); @Query(""" - SELECT c - FROM Prerequisite c - LEFT JOIN FETCH c.lectureUnits lu - WHERE c.id = :competencyId + SELECT p + FROM Prerequisite p + LEFT JOIN FETCH p.lectureUnits lu + WHERE p.id = :competencyId """) Optional findByIdWithLectureUnits(@Param("competencyId") long competencyId); diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/LearningObjectImportService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/LearningObjectImportService.java new file mode 100644 index 000000000000..6b67d8f01b44 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/LearningObjectImportService.java @@ -0,0 +1,445 @@ +package de.tum.cit.aet.artemis.atlas.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.io.IOException; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.util.function.ThrowingBiFunction; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import de.tum.cit.aet.artemis.assessment.domain.GradingCriterion; +import de.tum.cit.aet.artemis.assessment.repository.GradingCriterionRepository; +import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; +import de.tum.cit.aet.artemis.atlas.dto.CompetencyImportOptionsDTO; +import de.tum.cit.aet.artemis.atlas.dto.CompetencyWithTailRelationDTO; +import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; +import de.tum.cit.aet.artemis.core.domain.Course; +import de.tum.cit.aet.artemis.core.exception.NoUniqueQueryException; +import de.tum.cit.aet.artemis.exercise.domain.Exercise; +import de.tum.cit.aet.artemis.exercise.repository.ExerciseRepository; +import de.tum.cit.aet.artemis.fileupload.domain.FileUploadExercise; +import de.tum.cit.aet.artemis.fileupload.repository.FileUploadExerciseRepository; +import de.tum.cit.aet.artemis.fileupload.service.FileUploadExerciseImportService; +import de.tum.cit.aet.artemis.lecture.domain.Lecture; +import de.tum.cit.aet.artemis.lecture.domain.LectureUnit; +import de.tum.cit.aet.artemis.lecture.repository.LectureRepository; +import de.tum.cit.aet.artemis.lecture.repository.LectureUnitRepository; +import de.tum.cit.aet.artemis.lecture.service.LectureImportService; +import de.tum.cit.aet.artemis.lecture.service.LectureUnitImportService; +import de.tum.cit.aet.artemis.modeling.domain.ModelingExercise; +import de.tum.cit.aet.artemis.modeling.repository.ModelingExerciseRepository; +import de.tum.cit.aet.artemis.modeling.service.ModelingExerciseImportService; +import de.tum.cit.aet.artemis.plagiarism.service.PlagiarismDetectionConfigHelper; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; +import de.tum.cit.aet.artemis.programming.repository.hestia.ProgrammingExerciseTaskRepository; +import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseImportService; +import de.tum.cit.aet.artemis.quiz.domain.QuizExercise; +import de.tum.cit.aet.artemis.quiz.repository.QuizExerciseRepository; +import de.tum.cit.aet.artemis.quiz.service.QuizExerciseImportService; +import de.tum.cit.aet.artemis.text.domain.TextExercise; +import de.tum.cit.aet.artemis.text.repository.TextExerciseRepository; +import de.tum.cit.aet.artemis.text.service.TextExerciseImportService; + +/** + * Service for importing learning objects related to competencies. + */ +@Profile(PROFILE_CORE) +@Service +public class LearningObjectImportService { + + private static final Logger log = LoggerFactory.getLogger(LearningObjectImportService.class); + + private final ExerciseRepository exerciseRepository; + + private final ProgrammingExerciseRepository programmingExerciseRepository; + + private final ProgrammingExerciseImportService programmingExerciseImportService; + + private final FileUploadExerciseRepository fileUploadExerciseRepository; + + private final FileUploadExerciseImportService fileUploadExerciseImportService; + + private final ModelingExerciseRepository modelingExerciseRepository; + + private final ModelingExerciseImportService modelingExerciseImportService; + + private final TextExerciseRepository textExerciseRepository; + + private final TextExerciseImportService textExerciseImportService; + + private final QuizExerciseRepository quizExerciseRepository; + + private final QuizExerciseImportService quizExerciseImportService; + + private final LectureRepository lectureRepository; + + private final LectureImportService lectureImportService; + + private final LectureUnitRepository lectureUnitRepository; + + private final LectureUnitImportService lectureUnitImportService; + + private final CourseCompetencyRepository courseCompetencyRepository; + + private final ProgrammingExerciseTaskRepository programmingExerciseTaskRepository; + + private final GradingCriterionRepository gradingCriterionRepository; + + public LearningObjectImportService(ExerciseRepository exerciseRepository, ProgrammingExerciseRepository programmingExerciseRepository, + ProgrammingExerciseImportService programmingExerciseImportService, FileUploadExerciseRepository fileUploadExerciseRepository, + FileUploadExerciseImportService fileUploadExerciseImportService, ModelingExerciseRepository modelingExerciseRepository, + ModelingExerciseImportService modelingExerciseImportService, TextExerciseRepository textExerciseRepository, TextExerciseImportService textExerciseImportService, + QuizExerciseRepository quizExerciseRepository, QuizExerciseImportService quizExerciseImportService, LectureRepository lectureRepository, + LectureImportService lectureImportService, LectureUnitRepository lectureUnitRepository, LectureUnitImportService lectureUnitImportService, + CourseCompetencyRepository courseCompetencyRepository, ProgrammingExerciseTaskRepository programmingExerciseTaskRepository, + GradingCriterionRepository gradingCriterionRepository) { + this.exerciseRepository = exerciseRepository; + this.programmingExerciseRepository = programmingExerciseRepository; + this.programmingExerciseImportService = programmingExerciseImportService; + this.fileUploadExerciseRepository = fileUploadExerciseRepository; + this.fileUploadExerciseImportService = fileUploadExerciseImportService; + this.modelingExerciseRepository = modelingExerciseRepository; + this.modelingExerciseImportService = modelingExerciseImportService; + this.textExerciseRepository = textExerciseRepository; + this.textExerciseImportService = textExerciseImportService; + this.quizExerciseRepository = quizExerciseRepository; + this.quizExerciseImportService = quizExerciseImportService; + this.lectureRepository = lectureRepository; + this.lectureImportService = lectureImportService; + this.lectureUnitRepository = lectureUnitRepository; + this.lectureUnitImportService = lectureUnitImportService; + this.courseCompetencyRepository = courseCompetencyRepository; + this.programmingExerciseTaskRepository = programmingExerciseTaskRepository; + this.gradingCriterionRepository = gradingCriterionRepository; + } + + /** + * Imports the related learning objects from the source course competencies into the course to import into and links them to the imported competencies. + * + * @param sourceCourseCompetencies The source course competencies to import from. + * @param idToImportedCompetency A map from the source competency IDs to the imported competencies. + * @param courseToImportInto The course to import the learning objects into. + * @param importOptions The import options. + */ + public void importRelatedLearningObjects(Collection sourceCourseCompetencies, Map idToImportedCompetency, + Course courseToImportInto, CompetencyImportOptionsDTO importOptions) { + Set importedCourseCompetencies = idToImportedCompetency.values().stream().map(CompetencyWithTailRelationDTO::competency).collect(Collectors.toSet()); + + Set importedExercises = new HashSet<>(); + if (importOptions.importExercises()) { + importOrLoadExercises(sourceCourseCompetencies, idToImportedCompetency, courseToImportInto, importedExercises); + } + Map titleToImportedLectures = new HashMap<>(); + Set importedLectureUnits = new HashSet<>(); + if (importOptions.importLectures()) { + importOrLoadLectureUnits(sourceCourseCompetencies, idToImportedCompetency, courseToImportInto, titleToImportedLectures, importedLectureUnits); + } + Set importedLectures = new HashSet<>(titleToImportedLectures.values()); + + if (importOptions.referenceDate().isPresent()) { + setAllDates(importedExercises, importedLectures, importedLectureUnits, importedCourseCompetencies, importOptions.referenceDate().get(), importOptions.isReleaseDate()); + } + + courseCompetencyRepository.saveAll(importedCourseCompetencies); + exerciseRepository.saveAll(importedExercises); + lectureRepository.saveAll(importedLectures); + } + + private void importOrLoadExercises(Collection sourceCourseCompetencies, Map idToImportedCompetency, + Course courseToImportInto, Set importedExercises) { + for (CourseCompetency sourceCourseCompetency : sourceCourseCompetencies) { + for (Exercise sourceExercise : sourceCourseCompetency.getExercises()) { + try { + Exercise importedExercise = importOrLoadExercise(sourceExercise, courseToImportInto); + + importedExercises.add(importedExercise); + + importedExercise.getCompetencies().add(idToImportedCompetency.get(sourceCourseCompetency.getId()).competency()); + idToImportedCompetency.get(sourceCourseCompetency.getId()).competency().getExercises().add(importedExercise); + } + catch (Exception e) { + log.error("Failed to import exercise with title {} together with its competency with id {}", sourceExercise.getTitle(), sourceCourseCompetency.getId(), e); + } + } + } + } + + private Exercise importOrLoadExercise(Exercise sourceExercise, Course course) throws JsonProcessingException { + return switch (sourceExercise) { + case ProgrammingExercise programmingExercise -> importOrLoadProgrammingExercise(programmingExercise, course); + case FileUploadExercise fileUploadExercise -> + importOrLoadExercise(fileUploadExercise, course, fileUploadExerciseRepository::findUniqueWithCompetenciesByTitleAndCourseId, + fileUploadExerciseRepository::findWithGradingCriteriaByIdElseThrow, fileUploadExerciseImportService::importFileUploadExercise); + case ModelingExercise modelingExercise -> importOrLoadExercise(modelingExercise, course, modelingExerciseRepository::findUniqueWithCompetenciesByTitleAndCourseId, + modelingExerciseRepository::findByIdWithExampleSubmissionsAndResultsAndPlagiarismDetectionConfigElseThrow, + modelingExerciseImportService::importModelingExercise); + case TextExercise textExercise -> importOrLoadExercise(textExercise, course, textExerciseRepository::findUniqueWithCompetenciesByTitleAndCourseId, + textExerciseRepository::findByIdWithExampleSubmissionsAndResultsAndGradingCriteriaElseThrow, textExerciseImportService::importTextExercise); + case QuizExercise quizExercise -> importOrLoadExercise(quizExercise, course, quizExerciseRepository::findUniqueWithCompetenciesByTitleAndCourseId, + quizExerciseRepository::findByIdWithQuestionsAndStatisticsAndCompetenciesAndBatchesAndGradingCriteriaElseThrow, (exercise, templateExercise) -> { + try { + return quizExerciseImportService.importQuizExercise(exercise, templateExercise, null); + } + catch (IOException e) { + throw new RuntimeException(e); + } + }); + default -> throw new IllegalStateException("Unexpected value: " + sourceExercise); + }; + } + + private Exercise importOrLoadProgrammingExercise(ProgrammingExercise programmingExercise, Course course) throws JsonProcessingException { + Optional foundByTitle = programmingExerciseRepository.findWithCompetenciesByTitleAndCourseId(programmingExercise.getTitle(), course.getId()); + Optional foundByShortName = programmingExerciseRepository.findByShortNameAndCourseIdWithCompetencies(programmingExercise.getShortName(), + course.getId()); + + if (foundByTitle.isPresent() && foundByShortName.isPresent() && !foundByTitle.get().equals(foundByShortName.get())) { + throw new IllegalArgumentException("Two programming exercises with the title or short name already exist in the course"); + } + + if (foundByTitle.isPresent()) { + return foundByTitle.get(); + } + else if (foundByShortName.isPresent()) { + return foundByShortName.get(); + } + else { + programmingExercise = programmingExerciseRepository.findByIdForImportElseThrow(programmingExercise.getId()); + // Fetching the tasks separately, as putting it in the query above leads to Hibernate duplicating the tasks. + var templateTasks = programmingExerciseTaskRepository.findByExerciseIdWithTestCases(programmingExercise.getId()); + programmingExercise.setTasks(new ArrayList<>(templateTasks)); + Set gradingCriteria = gradingCriterionRepository.findByExerciseIdWithEagerGradingCriteria(programmingExercise.getId()); + programmingExercise.setGradingCriteria(gradingCriteria); + + ProgrammingExercise newExercise = programmingExerciseRepository + .findByIdWithTemplateAndSolutionParticipationTeamAssignmentConfigCategoriesAndCompetenciesAndPlagiarismDetectionConfigAndBuildConfigElseThrow( + programmingExercise.getId()); + PlagiarismDetectionConfigHelper.createAndSaveDefaultIfNullAndCourseExercise(newExercise, programmingExerciseRepository); + newExercise.setCourse(course); + newExercise.forceNewProjectKey(); + + clearProgrammingExerciseAttributes(newExercise); + + return programmingExerciseImportService.importProgrammingExercise(programmingExercise, newExercise, false, false, false); + } + } + + private void clearProgrammingExerciseAttributes(ProgrammingExercise programmingExercise) { + programmingExercise.setTasks(null); + programmingExercise.setExerciseHints(new HashSet<>()); + programmingExercise.setTestCases(new HashSet<>()); + programmingExercise.setStaticCodeAnalysisCategories(new HashSet<>()); + programmingExercise.setTeams(new HashSet<>()); + programmingExercise.setGradingCriteria(new HashSet<>()); + programmingExercise.setStudentParticipations(new HashSet<>()); + programmingExercise.setTutorParticipations(new HashSet<>()); + programmingExercise.setExampleSubmissions(new HashSet<>()); + programmingExercise.setAttachments(new HashSet<>()); + programmingExercise.setPosts(new HashSet<>()); + programmingExercise.setPlagiarismCases(new HashSet<>()); + programmingExercise.setCompetencies(new HashSet<>()); + } + + /** + * Imports or loads an exercise. + * + * @param exercise The source exercise for the import + * @param course The course to import the exercise into + * @param findFunction The function to find an existing exercise by title + * @param loadForImport The function to load an exercise for import + * @param importFunction The function to import the exercise + * @return The imported or loaded exercise + * @param The type of the exercise + */ + private Exercise importOrLoadExercise(E exercise, Course course, ThrowingBiFunction> findFunction, + Function loadForImport, BiFunction importFunction) { + Optional foundByTitle = findFunction.apply(exercise.getTitle(), course.getId()); + if (foundByTitle.isPresent()) { + return foundByTitle.get(); + } + else { + exercise = loadForImport.apply(exercise.getId()); + exercise.setCourse(course); + exercise.setId(null); + exercise.setCompetencies(new HashSet<>()); + + return importFunction.apply(exercise, exercise); + } + } + + /** + * Imports or loads a lecture unit. If the lecture unit needs to be imported, the lecture is imported or loaded as well. + * + * @param sourceCourseCompetencies The source course competencies to import from + * @param idToImportedCompetency A map from the source competency IDs to the imported competencies + * @param courseToImportInto The course to import the lecture unit into + * @param titleToImportedLectures A map from the source lecture titles to the imported lectures + * @param importedLectureUnits The set of imported lecture units + */ + private void importOrLoadLectureUnits(Collection sourceCourseCompetencies, Map idToImportedCompetency, + Course courseToImportInto, Map titleToImportedLectures, Set importedLectureUnits) { + for (CourseCompetency sourceCourseCompetency : sourceCourseCompetencies) { + for (LectureUnit sourceLectureUnit : sourceCourseCompetency.getLectureUnits()) { + try { + importOrLoadLectureUnit(sourceLectureUnit, sourceCourseCompetency, idToImportedCompetency, courseToImportInto, titleToImportedLectures, importedLectureUnits); + } + catch (Exception e) { + log.error("Failed to import lecture unit with name {} together with its competency with id {}", sourceLectureUnit.getName(), sourceCourseCompetency.getId(), e); + } + } + } + } + + private void importOrLoadLectureUnit(LectureUnit sourceLectureUnit, CourseCompetency sourceCourseCompetency, Map idToImportedCompetency, + Course courseToImportInto, Map titleToImportedLectures, Set importedLectureUnits) throws NoUniqueQueryException { + Lecture sourceLecture = sourceLectureUnit.getLecture(); + Lecture importedLecture = importOrLoadLecture(sourceLecture, courseToImportInto, titleToImportedLectures); + + Optional foundLectureUnit = lectureUnitRepository.findByNameAndLectureTitleAndCourseIdWithCompetencies(sourceLectureUnit.getName(), sourceLecture.getTitle(), + courseToImportInto.getId()); + LectureUnit importedLectureUnit; + if (foundLectureUnit.isEmpty()) { + importedLectureUnit = lectureUnitImportService.importLectureUnit(sourceLectureUnit); + + importedLecture.getLectureUnits().add(importedLectureUnit); + importedLectureUnit.setLecture(importedLecture); + } + else { + importedLectureUnit = foundLectureUnit.get(); + } + + importedLectureUnits.add(importedLectureUnit); + + importedLectureUnit.getCompetencies().add(idToImportedCompetency.get(sourceCourseCompetency.getId()).competency()); + idToImportedCompetency.get(sourceCourseCompetency.getId()).competency().getLectureUnits().add(importedLectureUnit); + } + + private Lecture importOrLoadLecture(Lecture sourceLecture, Course courseToImportInto, Map titleToImportedLectures) throws NoUniqueQueryException { + Optional foundLecture = Optional.ofNullable(titleToImportedLectures.get(sourceLecture.getTitle())); + if (foundLecture.isEmpty()) { + foundLecture = lectureRepository.findUniqueByTitleAndCourseIdWithLectureUnitsElseThrow(sourceLecture.getTitle(), courseToImportInto.getId()); + } + Lecture importedLecture = foundLecture.orElseGet(() -> lectureImportService.importLecture(sourceLecture, courseToImportInto, false)); + titleToImportedLectures.put(importedLecture.getTitle(), importedLecture); + + return importedLecture; + } + + private void setAllDates(Set importedExercises, Set importedLectures, Set importedLectureUnits, + Set importedCourseCompetencies, ZonedDateTime referenceDate, boolean isReleaseDate) { + long timeOffset = determineTimeOffset(importedExercises, importedLectures, importedLectureUnits, importedCourseCompetencies, referenceDate, isReleaseDate); + if (timeOffset == 0) { + return; + } + + importedExercises.forEach(exercise -> setAllExerciseDates(exercise, timeOffset)); + importedLectures.forEach(lecture -> setAllLectureDates(lecture, timeOffset)); + importedLectureUnits.forEach(lectureUnit -> setAllLectureUnitDates(lectureUnit, timeOffset)); + importedCourseCompetencies.forEach(competency -> setAllCompetencyDates(competency, timeOffset)); + } + + /** + * Finds the earliest relevant time and determines the time offset to apply to the dates of the imported learning objects. + * + * @param importedExercises The imported exercises + * @param importedLectures The imported lectures + * @param importedLectureUnits The imported lecture units + * @param importedCourseCompetencies The imported competencies + * @param referenceDate The reference date to calculate the offset from + * @param isReleaseDate Whether the offset is for the release date or the due date + * @return The time offset to apply + */ + private long determineTimeOffset(Set importedExercises, Set importedLectures, Set importedLectureUnits, + Set importedCourseCompetencies, ZonedDateTime referenceDate, boolean isReleaseDate) { + Optional earliestTime; + + if (isReleaseDate) { + Stream exerciseDates = importedExercises.stream().map(Exercise::getReleaseDate); + Stream lectureDates = importedLectures.stream().map(Lecture::getVisibleDate); + Stream lectureUnitDates = importedLectureUnits.stream().map(LectureUnit::getReleaseDate); + earliestTime = Stream.concat(exerciseDates, Stream.concat(lectureDates, lectureUnitDates)).filter(Objects::nonNull).min(Comparator.naturalOrder()); + } + else { + Stream exerciseDates = importedExercises.stream().map(Exercise::getDueDate); + Stream lectureDates = importedLectures.stream().map(Lecture::getEndDate); + Stream competencyDates = importedCourseCompetencies.stream().map(CourseCompetency::getSoftDueDate); + earliestTime = Stream.concat(exerciseDates, Stream.concat(lectureDates, competencyDates)).filter(Objects::nonNull).min(Comparator.naturalOrder()); + } + + return earliestTime.map(zonedDateTime -> referenceDate.toEpochSecond() - zonedDateTime.toEpochSecond()).orElse(0L); + } + + private void setAllExerciseDates(Exercise exercise, long timeOffset) { + if (exercise.getReleaseDate() != null) { + exercise.setReleaseDate(exercise.getReleaseDate().plusSeconds(timeOffset)); + } + if (exercise.getStartDate() != null) { + exercise.setStartDate(exercise.getStartDate().plusSeconds(timeOffset)); + } + if (exercise.getDueDate() != null) { + exercise.setDueDate(exercise.getDueDate().plusSeconds(timeOffset)); + } + if (exercise.getAssessmentDueDate() != null) { + exercise.setAssessmentDueDate(exercise.getAssessmentDueDate().plusSeconds(timeOffset)); + } + if (exercise.getExampleSolutionPublicationDate() != null) { + exercise.setExampleSolutionPublicationDate(exercise.getExampleSolutionPublicationDate().plusSeconds(timeOffset)); + } + + if (exercise instanceof QuizExercise quizExercise && !quizExercise.getQuizBatches().isEmpty()) { + quizExercise.getQuizBatches().forEach(batch -> { + if (batch.getStartTime() != null) { + batch.setStartTime(batch.getStartTime().plusSeconds(timeOffset)); + } + }); + } + + if (exercise instanceof ProgrammingExercise programmingExercise && programmingExercise.getBuildAndTestStudentSubmissionsAfterDueDate() != null) { + programmingExercise.setBuildAndTestStudentSubmissionsAfterDueDate(programmingExercise.getBuildAndTestStudentSubmissionsAfterDueDate().plusSeconds(timeOffset)); + } + } + + private void setAllLectureDates(Lecture lecture, long timeOffset) { + if (lecture.getVisibleDate() != null) { + lecture.setVisibleDate(lecture.getVisibleDate().plusSeconds(timeOffset)); + } + if (lecture.getStartDate() != null) { + lecture.setStartDate(lecture.getStartDate().plusSeconds(timeOffset)); + } + if (lecture.getEndDate() != null) { + lecture.setEndDate(lecture.getEndDate().plusSeconds(timeOffset)); + } + } + + private void setAllLectureUnitDates(LectureUnit lectureUnit, long timeOffset) { + if (lectureUnit.getReleaseDate() != null) { + lectureUnit.setReleaseDate(lectureUnit.getReleaseDate().plusSeconds(timeOffset)); + } + } + + private void setAllCompetencyDates(CourseCompetency competency, long timeOffset) { + if (competency.getSoftDueDate() != null) { + competency.setSoftDueDate(competency.getSoftDueDate().plusSeconds(timeOffset)); + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyService.java index 9217aa5196ad..9ec942846bf8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CompetencyService.java @@ -13,12 +13,14 @@ import de.tum.cit.aet.artemis.atlas.domain.competency.Competency; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; +import de.tum.cit.aet.artemis.atlas.dto.CompetencyImportOptionsDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyWithTailRelationDTO; import de.tum.cit.aet.artemis.atlas.repository.CompetencyProgressRepository; import de.tum.cit.aet.artemis.atlas.repository.CompetencyRelationRepository; import de.tum.cit.aet.artemis.atlas.repository.CompetencyRepository; import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; import de.tum.cit.aet.artemis.atlas.repository.StandardizedCompetencyRepository; +import de.tum.cit.aet.artemis.atlas.service.LearningObjectImportService; import de.tum.cit.aet.artemis.atlas.service.learningpath.LearningPathService; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; @@ -38,20 +40,22 @@ public class CompetencyService extends CourseCompetencyService { public CompetencyService(CompetencyRepository competencyRepository, AuthorizationCheckService authCheckService, CompetencyRelationRepository competencyRelationRepository, LearningPathService learningPathService, CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService, CompetencyProgressRepository competencyProgressRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository, - StandardizedCompetencyRepository standardizedCompetencyRepository, CourseCompetencyRepository courseCompetencyRepository, ExerciseService exerciseService) { + StandardizedCompetencyRepository standardizedCompetencyRepository, CourseCompetencyRepository courseCompetencyRepository, ExerciseService exerciseService, + LearningObjectImportService learningObjectImportService) { super(competencyProgressRepository, courseCompetencyRepository, competencyRelationRepository, competencyProgressService, exerciseService, lectureUnitService, - learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository); + learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService); this.competencyRepository = competencyRepository; } /** * Imports the given competencies and relations into a course * - * @param course the course to import into - * @param competencies the competencies to import + * @param course the course to import into + * @param competencies the competencies to import + * @param importOptions the options for importing the competencies * @return The set of imported competencies, each also containing the relations it is the tail competency for. */ - public Set importCompetenciesAndRelations(Course course, Collection competencies) { + public Set importCompetencies(Course course, Collection competencies, CompetencyImportOptionsDTO importOptions) { var idToImportedCompetency = new HashMap(); for (var competency : competencies) { @@ -62,7 +66,7 @@ public Set importCompetenciesAndRelations(Course idToImportedCompetency.put(competency.getId(), new CompetencyWithTailRelationDTO(importedCompetency, new ArrayList<>())); } - return importCourseCompetenciesAndRelations(course, idToImportedCompetency); + return importCourseCompetencies(course, competencies, idToImportedCompetency, importOptions); } /** @@ -76,17 +80,6 @@ public List importStandardizedCompetencies(List competen return super.importStandardizedCompetencies(competencyIdsToImport, course, Competency::new); } - /** - * Imports the given course competencies into a course - * - * @param course the course to import into - * @param competencies the course competencies to import - * @return The list of imported competencies - */ - public Set importCompetencies(Course course, Collection competencies) { - return importCourseCompetencies(course, competencies, Competency::new); - } - /** * Creates a new competency and links it to a course and lecture units. * diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java index 05b4f0bca77e..01eb37cf8271 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/CourseCompetencyService.java @@ -24,12 +24,14 @@ import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; import de.tum.cit.aet.artemis.atlas.domain.competency.Prerequisite; import de.tum.cit.aet.artemis.atlas.domain.competency.StandardizedCompetency; +import de.tum.cit.aet.artemis.atlas.dto.CompetencyImportOptionsDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyRelationDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyWithTailRelationDTO; import de.tum.cit.aet.artemis.atlas.repository.CompetencyProgressRepository; import de.tum.cit.aet.artemis.atlas.repository.CompetencyRelationRepository; import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; import de.tum.cit.aet.artemis.atlas.repository.StandardizedCompetencyRepository; +import de.tum.cit.aet.artemis.atlas.service.LearningObjectImportService; import de.tum.cit.aet.artemis.atlas.service.learningpath.LearningPathService; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; @@ -73,10 +75,13 @@ public class CourseCompetencyService { protected final LectureUnitCompletionRepository lectureUnitCompletionRepository; + private final LearningObjectImportService learningObjectImportService; + public CourseCompetencyService(CompetencyProgressRepository competencyProgressRepository, CourseCompetencyRepository courseCompetencyRepository, CompetencyRelationRepository competencyRelationRepository, CompetencyProgressService competencyProgressService, ExerciseService exerciseService, LectureUnitService lectureUnitService, LearningPathService learningPathService, AuthorizationCheckService authCheckService, - StandardizedCompetencyRepository standardizedCompetencyRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository) { + StandardizedCompetencyRepository standardizedCompetencyRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository, + LearningObjectImportService learningObjectImportService) { this.competencyProgressRepository = competencyProgressRepository; this.courseCompetencyRepository = courseCompetencyRepository; this.competencyRelationRepository = competencyRelationRepository; @@ -87,6 +92,7 @@ public CourseCompetencyService(CompetencyProgressRepository competencyProgressRe this.authCheckService = authCheckService; this.standardizedCompetencyRepository = standardizedCompetencyRepository; this.lectureUnitCompletionRepository = lectureUnitCompletionRepository; + this.learningObjectImportService = learningObjectImportService; } /** @@ -158,9 +164,10 @@ public void filterOutLearningObjectsThatUserShouldNotSee(CourseCompetency compet * * @param course the course to import into * @param courseCompetencies the course competencies to import + * @param importOptions the import options * @return The set of imported course competencies, each also containing the relations it is the tail competency for. */ - public Set importCourseCompetenciesAndRelations(Course course, Collection courseCompetencies) { + public Set importCourseCompetencies(Course course, Collection courseCompetencies, CompetencyImportOptionsDTO importOptions) { var idToImportedCompetency = new HashMap(); for (var courseCompetency : courseCompetencies) { @@ -175,37 +182,45 @@ public Set importCourseCompetenciesAndRelations(C idToImportedCompetency.put(courseCompetency.getId(), new CompetencyWithTailRelationDTO(importedCompetency, new ArrayList<>())); } - return importCourseCompetenciesAndRelations(course, idToImportedCompetency); + return importCourseCompetencies(course, courseCompetencies, idToImportedCompetency, importOptions); } /** * Imports the given competencies and relations into a course * * @param course the course to import into + * @param competenciesToImport the source competencies that were imported * @param idToImportedCompetency map of original competency id to imported competency + * @param importOptions the import options * @return The set of imported competencies, each also containing the relations it is the tail competency for. */ - public Set importCourseCompetenciesAndRelations(Course course, Map idToImportedCompetency) { + public Set importCourseCompetencies(Course course, Collection competenciesToImport, + Map idToImportedCompetency, CompetencyImportOptionsDTO importOptions) { if (course.getLearningPathsEnabled()) { var importedCompetencies = idToImportedCompetency.values().stream().map(CompetencyWithTailRelationDTO::competency).toList(); learningPathService.linkCompetenciesToLearningPathsOfCourse(importedCompetencies, course.getId()); } - var originalCompetencyIds = idToImportedCompetency.keySet(); - var relations = competencyRelationRepository.findAllByHeadCompetencyIdInAndTailCompetencyIdIn(originalCompetencyIds, originalCompetencyIds); + if (importOptions.importRelations()) { + var originalCompetencyIds = idToImportedCompetency.keySet(); + var relations = competencyRelationRepository.findAllByHeadCompetencyIdInAndTailCompetencyIdIn(originalCompetencyIds, originalCompetencyIds); - for (var relation : relations) { - var tailCompetencyDTO = idToImportedCompetency.get(relation.getTailCompetency().getId()); - var headCompetencyDTO = idToImportedCompetency.get(relation.getHeadCompetency().getId()); + for (var relation : relations) { + var tailCompetencyDTO = idToImportedCompetency.get(relation.getTailCompetency().getId()); + var headCompetencyDTO = idToImportedCompetency.get(relation.getHeadCompetency().getId()); - CompetencyRelation relationToImport = new CompetencyRelation(); - relationToImport.setType(relation.getType()); - relationToImport.setTailCompetency(tailCompetencyDTO.competency()); - relationToImport.setHeadCompetency(headCompetencyDTO.competency()); + CompetencyRelation relationToImport = new CompetencyRelation(); + relationToImport.setType(relation.getType()); + relationToImport.setTailCompetency(tailCompetencyDTO.competency()); + relationToImport.setHeadCompetency(headCompetencyDTO.competency()); - relationToImport = competencyRelationRepository.save(relationToImport); - tailCompetencyDTO.tailRelations().add(CompetencyRelationDTO.of(relationToImport)); + relationToImport = competencyRelationRepository.save(relationToImport); + tailCompetencyDTO.tailRelations().add(CompetencyRelationDTO.of(relationToImport)); + } } + + learningObjectImportService.importRelatedLearningObjects(competenciesToImport, idToImportedCompetency, course, importOptions); + return new HashSet<>(idToImportedCompetency.values()); } @@ -247,51 +262,6 @@ public List importStandardizedCompetencies(List competen return importedCompetencies; } - /** - * Imports the given course competencies into a course - * - * @param course the course to import into - * @param courseCompetencies the course competencies to import - * @return The list of imported competencies - */ - public Set importCourseCompetencies(Course course, Collection courseCompetencies) { - Function courseCompetencyFunction = courseCompetency -> switch (courseCompetency) { - case Competency competency -> new Competency(competency); - case Prerequisite prerequisite -> new Prerequisite(prerequisite); - default -> throw new IllegalStateException("Unexpected value: " + courseCompetency); - }; - return importCourseCompetencies(course, courseCompetencies, courseCompetencyFunction); - } - - /** - * Imports the given course competencies into a course - * - * @param course the course to import into - * @param competencies the course competencies to import - * @param courseCompetencyFunction the function that creates new course competencies - * @return The set of imported competencies - */ - public Set importCourseCompetencies(Course course, Collection competencies, - Function courseCompetencyFunction) { - var importedCompetencies = new ArrayList(); - Set createdDTOs = new HashSet<>(); - - for (var competency : competencies) { - CourseCompetency importedCompetency = courseCompetencyFunction.apply(competency); - importedCompetency.setCourse(course); - - importedCompetency = courseCompetencyRepository.save(importedCompetency); - importedCompetencies.add(importedCompetency); - createdDTOs.add(new CompetencyWithTailRelationDTO(importedCompetency, Collections.emptyList())); - } - - if (course.getLearningPathsEnabled()) { - learningPathService.linkCompetenciesToLearningPathsOfCourse(importedCompetencies, course.getId()); - } - - return createdDTOs; - } - /** * Creates a new competency and links it to a course and lecture units. * If learning paths are enabled, the competency is also linked to the learning paths of the course. diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/LearningObjectImportService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/LearningObjectImportService.java new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/PrerequisiteService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/PrerequisiteService.java index aff6e8927bd7..3fc520a21378 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/PrerequisiteService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/competency/PrerequisiteService.java @@ -13,12 +13,14 @@ import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; import de.tum.cit.aet.artemis.atlas.domain.competency.Prerequisite; +import de.tum.cit.aet.artemis.atlas.dto.CompetencyImportOptionsDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyWithTailRelationDTO; import de.tum.cit.aet.artemis.atlas.repository.CompetencyProgressRepository; import de.tum.cit.aet.artemis.atlas.repository.CompetencyRelationRepository; import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; import de.tum.cit.aet.artemis.atlas.repository.PrerequisiteRepository; import de.tum.cit.aet.artemis.atlas.repository.StandardizedCompetencyRepository; +import de.tum.cit.aet.artemis.atlas.service.LearningObjectImportService; import de.tum.cit.aet.artemis.atlas.service.learningpath.LearningPathService; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; @@ -38,9 +40,10 @@ public class PrerequisiteService extends CourseCompetencyService { public PrerequisiteService(PrerequisiteRepository prerequisiteRepository, AuthorizationCheckService authCheckService, CompetencyRelationRepository competencyRelationRepository, LearningPathService learningPathService, CompetencyProgressService competencyProgressService, LectureUnitService lectureUnitService, CompetencyProgressRepository competencyProgressRepository, LectureUnitCompletionRepository lectureUnitCompletionRepository, - StandardizedCompetencyRepository standardizedCompetencyRepository, CourseCompetencyRepository courseCompetencyRepository, ExerciseService exerciseService) { + StandardizedCompetencyRepository standardizedCompetencyRepository, CourseCompetencyRepository courseCompetencyRepository, ExerciseService exerciseService, + LearningObjectImportService learningObjectImportService) { super(competencyProgressRepository, courseCompetencyRepository, competencyRelationRepository, competencyProgressService, exerciseService, lectureUnitService, - learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository); + learningPathService, authCheckService, standardizedCompetencyRepository, lectureUnitCompletionRepository, learningObjectImportService); this.prerequisiteRepository = prerequisiteRepository; } @@ -49,9 +52,10 @@ public PrerequisiteService(PrerequisiteRepository prerequisiteRepository, Author * * @param course the course to import into * @param prerequisites the prerequisites to import + * @param importOptions the options for importing the prerequisites * @return The set of imported prerequisites, each also containing the relations for which it is the tail prerequisite for. */ - public Set importPrerequisitesAndRelations(Course course, Collection prerequisites) { + public Set importPrerequisites(Course course, Collection prerequisites, CompetencyImportOptionsDTO importOptions) { var idToImportedPrerequisite = new HashMap(); for (var prerequisite : prerequisites) { @@ -62,7 +66,7 @@ public Set importPrerequisitesAndRelations(Course idToImportedPrerequisite.put(prerequisite.getId(), new CompetencyWithTailRelationDTO(importedPrerequisite, new ArrayList<>())); } - return importCourseCompetenciesAndRelations(course, idToImportedPrerequisite); + return importCourseCompetencies(course, prerequisites, idToImportedPrerequisite, importOptions); } /** @@ -76,17 +80,6 @@ public List importStandardizedPrerequisites(List prerequ return super.importStandardizedCompetencies(prerequisiteIdsToImport, course, Prerequisite::new); } - /** - * Imports the given course prerequisites into a course - * - * @param course the course to import into - * @param prerequisites the course prerequisites to import - * @return The list of imported prerequisites - */ - public Set importPrerequisites(Course course, Collection prerequisites) { - return importCourseCompetencies(course, prerequisites, Prerequisite::new); - } - /** * Creates a new prerequisite and links it to a course and lecture units. * diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/CompetencyResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/CompetencyResource.java index aa240c5f9a42..aa1a9f78dc0e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/CompetencyResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/CompetencyResource.java @@ -8,7 +8,6 @@ import java.util.Set; import jakarta.validation.constraints.NotNull; -import jakarta.ws.rs.BadRequestException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,11 +21,11 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import de.tum.cit.aet.artemis.atlas.domain.competency.Competency; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; +import de.tum.cit.aet.artemis.atlas.dto.CompetencyImportOptionsDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyImportResponseDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyWithTailRelationDTO; import de.tum.cit.aet.artemis.atlas.repository.CompetencyRepository; @@ -136,9 +135,8 @@ public ResponseEntity getCompetency(@PathVariable long competencyId, @EnforceAtLeastInstructorInCourse public ResponseEntity createCompetency(@PathVariable long courseId, @RequestBody Competency competency) throws URISyntaxException { log.debug("REST request to create Competency : {}", competency); - if (competency.getId() != null || competency.getTitle() == null || competency.getTitle().trim().isEmpty()) { - throw new BadRequestException(); - } + checkCompetencyAttributesForCreation(competency); + var course = courseRepository.findWithEagerCompetenciesAndPrerequisitesByIdElseThrow(courseId); final var persistedCompetency = competencyService.createCourseCompetency(competency, course); @@ -159,9 +157,7 @@ public ResponseEntity createCompetency(@PathVariable long courseId, public ResponseEntity> createCompetencies(@PathVariable Long courseId, @RequestBody List competencies) throws URISyntaxException { log.debug("REST request to create Competencies : {}", competencies); for (Competency competency : competencies) { - if (competency.getId() != null || competency.getTitle() == null || competency.getTitle().trim().isEmpty()) { - throw new BadRequestException(); - } + checkCompetencyAttributesForCreation(competency); } var course = courseRepository.findWithEagerCompetenciesAndPrerequisitesByIdElseThrow(courseId); @@ -173,25 +169,31 @@ public ResponseEntity> createCompetencies(@PathVariable Long co /** * POST courses/:courseId/competencies/import : imports a new competency. * - * @param courseId the id of the course to which the competency should be imported to - * @param competencyId the id of the competency that should be imported + * @param courseId the id of the course to which the competency should be imported to + * @param importOptions the options for the import * @return the ResponseEntity with status 201 (Created) and with body containing the imported competency * @throws URISyntaxException if the Location URI syntax is incorrect */ @PostMapping("courses/{courseId}/competencies/import") @EnforceAtLeastInstructorInCourse - public ResponseEntity importCompetency(@PathVariable long courseId, @RequestBody long competencyId) throws URISyntaxException { - log.info("REST request to import a competency: {}", competencyId); + public ResponseEntity importCompetency(@PathVariable long courseId, @RequestBody CompetencyImportOptionsDTO importOptions) throws URISyntaxException { + log.info("REST request to import a competency: {}", importOptions.competencyIds()); + + if (importOptions.competencyIds() == null || importOptions.competencyIds().size() != 1) { + throw new BadRequestAlertException("Exactly one competency must be imported", ENTITY_NAME, "noCompetency"); + } + long competencyId = importOptions.competencyIds().iterator().next(); var course = courseRepository.findWithEagerCompetenciesAndPrerequisitesByIdElseThrow(courseId); - var competencyToImport = courseCompetencyRepository.findByIdElseThrow(competencyId); + var competencyToImport = courseCompetencyRepository.findByIdWithExercisesAndLectureUnitsAndLecturesElseThrow(competencyId); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, competencyToImport.getCourse(), null); if (competencyToImport.getCourse().getId().equals(courseId)) { throw new BadRequestAlertException("The competency is already added to this course", ENTITY_NAME, "competencyCycle"); } - Competency createdCompetency = competencyService.createCompetency(competencyToImport, course); + Set createdCompetencies = competencyService.importCompetencies(course, Set.of(competencyToImport), importOptions); + Competency createdCompetency = (Competency) createdCompetencies.iterator().next().competency(); return ResponseEntity.created(new URI("/api/courses/" + courseId + "/competencies/" + createdCompetency.getId())).body(createdCompetency); } @@ -199,21 +201,24 @@ public ResponseEntity importCompetency(@PathVariable long courseId, /** * POST courses/:courseId/competencies/import/bulk : imports a number of competencies (and optionally their relations) into a course. * - * @param courseId the id of the course to which the competencies should be imported to - * @param competencyIds the ids of the competencies that should be imported - * @param importRelations if relations should be imported as well + * @param courseId the id of the course to which the competencies should be imported to + * @param importOptions the options for the import * @return the ResponseEntity with status 201 (Created) and with body containing the imported competencies * @throws URISyntaxException if the Location URI syntax is incorrect */ @PostMapping("courses/{courseId}/competencies/import/bulk") @EnforceAtLeastEditorInCourse - public ResponseEntity> importCompetencies(@PathVariable long courseId, @RequestBody Set competencyIds, - @RequestParam(defaultValue = "false") boolean importRelations) throws URISyntaxException { - log.info("REST request to import competencies: {}", competencyIds); + public ResponseEntity> importCompetencies(@PathVariable long courseId, @RequestBody CompetencyImportOptionsDTO importOptions) + throws URISyntaxException { + log.info("REST request to import competencies: {}", importOptions.competencyIds()); + + if (importOptions.competencyIds() == null || importOptions.competencyIds().isEmpty()) { + throw new BadRequestAlertException("No competencies to import", ENTITY_NAME, "noCompetencies"); + } var course = courseRepository.findWithEagerCompetenciesAndPrerequisitesByIdElseThrow(courseId); - List competenciesToImport = courseCompetencyRepository.findAllById(competencyIds); + Set competenciesToImport = courseCompetencyRepository.findAllByIdWithExercisesAndLectureUnitsAndLecturesAndAttachments(importOptions.competencyIds()); User user = userRepository.getUserWithGroupsAndAuthorities(); competenciesToImport.forEach(competencyToImport -> { @@ -223,48 +228,37 @@ public ResponseEntity> importCompetencies(@Pa } }); - Set importedCompetencies; - if (importRelations) { - importedCompetencies = competencyService.importCompetenciesAndRelations(course, competenciesToImport); - } - else { - importedCompetencies = competencyService.importCompetencies(course, competenciesToImport); - } + Set importedCompetencies = competencyService.importCompetencies(course, competenciesToImport, importOptions); return ResponseEntity.created(new URI("/api/courses/" + courseId + "/competencies/")).body(importedCompetencies); } /** - * POST courses/{courseId}/competencies/import-all/{sourceCourseId} : Imports all competencies of the source course (and optionally their relations) into another. + * POST courses/{courseId}/competencies/import-all : Imports all competencies of the source course (and optionally their relations) into another. * - * @param courseId the id of the course to import into - * @param sourceCourseId the id of the course to import from - * @param importRelations if relations should be imported as well + * @param courseId the id of the course to import into + * @param importOptions the options for the import * @return the ResponseEntity with status 201 (Created) and with body containing the imported competencies (and relations) * @throws URISyntaxException if the Location URI syntax is incorrect */ - @PostMapping("courses/{courseId}/competencies/import-all/{sourceCourseId}") + @PostMapping("courses/{courseId}/competencies/import-all") @EnforceAtLeastInstructorInCourse - public ResponseEntity> importAllCompetenciesFromCourse(@PathVariable long courseId, @PathVariable long sourceCourseId, - @RequestParam(defaultValue = "false") boolean importRelations) throws URISyntaxException { - log.info("REST request to all competencies from course {} into course {}", sourceCourseId, courseId); + public ResponseEntity> importAllCompetenciesFromCourse(@PathVariable long courseId, @RequestBody CompetencyImportOptionsDTO importOptions) + throws URISyntaxException { + log.info("REST request to all competencies from course {} into course {}", importOptions.sourceCourseId(), courseId); - if (courseId == sourceCourseId) { - throw new BadRequestAlertException("Cannot import from a course into itself", "Course", "courseCycle"); + if (importOptions.sourceCourseId().isEmpty()) { + throw new BadRequestAlertException("No source course specified", ENTITY_NAME, "noSourceCourse"); + } + else if (courseId == importOptions.sourceCourseId().get()) { + throw new BadRequestAlertException("Cannot import from a course into itself", ENTITY_NAME, "courseCycle"); } var targetCourse = courseRepository.findByIdElseThrow(courseId); - var sourceCourse = courseRepository.findByIdElseThrow(sourceCourseId); + var sourceCourse = courseRepository.findByIdElseThrow(importOptions.sourceCourseId().get()); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, sourceCourse, null); - var competencies = competencyRepository.findAllForCourse(sourceCourse.getId()); - Set importedCompetencies; - - if (importRelations) { - importedCompetencies = competencyService.importCompetenciesAndRelations(targetCourse, competencies); - } - else { - importedCompetencies = competencyService.importCompetencies(targetCourse, competencies); - } + var competencies = competencyRepository.findAllForCourseWithExercisesAndLectureUnitsAndLecturesAndAttachments(sourceCourse.getId()); + Set importedCompetencies = competencyService.importCompetencies(targetCourse, competencies, importOptions); return ResponseEntity.created(new URI("/api/courses/" + courseId + "/competencies/")).body(importedCompetencies); } @@ -300,9 +294,8 @@ public ResponseEntity> importStandardizedCompe @EnforceAtLeastInstructorInCourse public ResponseEntity updateCompetency(@PathVariable long courseId, @RequestBody Competency competency) { log.debug("REST request to update Competency : {}", competency); - if (competency.getId() == null) { - throw new BadRequestException(); - } + checkCompetencyAttributesForUpdate(competency); + var course = courseRepository.findByIdElseThrow(courseId); var existingCompetency = competencyRepository.findByIdWithLectureUnitsElseThrow(competency.getId()); checkCourseForCompetency(course, existingCompetency); @@ -334,6 +327,26 @@ public ResponseEntity deleteCompetency(@PathVariable long competencyId, @P return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, competency.getTitle())).build(); } + private void checkCompetencyAttributesForCreation(Competency competency) { + if (competency.getId() != null) { + throw new BadRequestAlertException("A new competency should not have an id", ENTITY_NAME, "existingCompetencyId"); + } + checkCompetencyAttributes(competency); + } + + private void checkCompetencyAttributesForUpdate(Competency competency) { + if (competency.getId() == null) { + throw new BadRequestAlertException("An updated competency should have an id", ENTITY_NAME, "missingCompetencyId"); + } + checkCompetencyAttributes(competency); + } + + private void checkCompetencyAttributes(Competency competency) { + if (competency.getTitle() == null || competency.getTitle().trim().isEmpty() || competency.getMasteryThreshold() < 1 || competency.getMasteryThreshold() > 100) { + throw new BadRequestAlertException("The attributes of the competency are invalid!", ENTITY_NAME, "invalidPrerequisiteAttributes"); + } + } + /** * Checks if the competency matches the course. * diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java index d335c9285f59..449a92a1d171 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/CourseCompetencyResource.java @@ -28,6 +28,7 @@ import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyProgress; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; +import de.tum.cit.aet.artemis.atlas.dto.CompetencyImportOptionsDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyJolPairDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyRelationDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyWithTailRelationDTO; @@ -232,36 +233,31 @@ public ResponseEntity> getCompetenciesForI } /** - * POST courses/{courseId}/course-competencies/import-all/{sourceCourseId} : Imports all course competencies of the source course (and optionally their relations) into another. + * POST courses/{courseId}/course-competencies/import-all : Imports all course competencies of the source course (and optionally their relations) into another. * - * @param courseId the id of the course to import into - * @param sourceCourseId the id of the course to import from - * @param importRelations if relations should be imported as well + * @param courseId the id of the course to import into + * @param importOptions the options for the import * @return the ResponseEntity with status 201 (Created) and with body containing the imported competencies (and relations) * @throws URISyntaxException if the Location URI syntax is incorrect */ - @PostMapping("courses/{courseId}/course-competencies/import-all/{sourceCourseId}") + @PostMapping("courses/{courseId}/course-competencies/import-all") @EnforceAtLeastInstructorInCourse - public ResponseEntity> importAllCompetenciesFromCourse(@PathVariable long courseId, @PathVariable long sourceCourseId, - @RequestParam(defaultValue = "false") boolean importRelations) throws URISyntaxException { - log.info("REST request to all course competencies from course {} into course {}", sourceCourseId, courseId); + public ResponseEntity> importAllCompetenciesFromCourse(@PathVariable long courseId, @RequestBody CompetencyImportOptionsDTO importOptions) + throws URISyntaxException { + log.info("REST request to all course competencies from course {} into course {}", importOptions.sourceCourseId(), courseId); - if (courseId == sourceCourseId) { - throw new BadRequestAlertException("Cannot import from a course into itself", "Course", "courseCycle"); + if (importOptions.sourceCourseId().isEmpty()) { + throw new BadRequestAlertException("No source course specified", ENTITY_NAME, "noSourceCourse"); + } + else if (courseId == importOptions.sourceCourseId().get()) { + throw new BadRequestAlertException("Cannot import from a course into itself", ENTITY_NAME, "courseCycle"); } var targetCourse = courseRepository.findByIdElseThrow(courseId); - var sourceCourse = courseRepository.findByIdElseThrow(sourceCourseId); + var sourceCourse = courseRepository.findByIdElseThrow(importOptions.sourceCourseId().get()); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, sourceCourse, null); - var competencies = courseCompetencyRepository.findAllForCourse(sourceCourse.getId()); - Set importedCompetencies; - - if (importRelations) { - importedCompetencies = courseCompetencyService.importCourseCompetenciesAndRelations(targetCourse, competencies); - } - else { - importedCompetencies = courseCompetencyService.importCourseCompetencies(targetCourse, competencies); - } + var competencies = courseCompetencyRepository.findAllForCourseWithExercisesAndLectureUnitsAndLecturesAndAttachments(sourceCourse.getId()); + Set importedCompetencies = courseCompetencyService.importCourseCompetencies(targetCourse, competencies, importOptions); return ResponseEntity.created(new URI("/api/courses/" + courseId + "/competencies/")).body(importedCompetencies); } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/PrerequisiteResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/PrerequisiteResource.java index 00e0cdfa3d31..a9be53c3c4a4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/PrerequisiteResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/PrerequisiteResource.java @@ -8,7 +8,6 @@ import java.util.Set; import jakarta.validation.constraints.NotNull; -import jakarta.ws.rs.BadRequestException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,11 +21,11 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import de.tum.cit.aet.artemis.atlas.domain.competency.CourseCompetency; import de.tum.cit.aet.artemis.atlas.domain.competency.Prerequisite; +import de.tum.cit.aet.artemis.atlas.dto.CompetencyImportOptionsDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyImportResponseDTO; import de.tum.cit.aet.artemis.atlas.dto.CompetencyWithTailRelationDTO; import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; @@ -140,9 +139,8 @@ public ResponseEntity getPrerequisite(@PathVariable long prerequis @EnforceAtLeastInstructorInCourse public ResponseEntity createPrerequisite(@PathVariable long courseId, @RequestBody Prerequisite prerequisite) throws URISyntaxException { log.debug("REST request to create Prerequisite : {}", prerequisite); - if (prerequisite.getId() != null || prerequisite.getTitle() == null || prerequisite.getTitle().trim().isEmpty()) { - throw new BadRequestException(); - } + checkPrerequisitesAttributesForCreation(prerequisite); + var course = courseRepository.findWithEagerCompetenciesAndPrerequisitesByIdElseThrow(courseId); final var persistedPrerequisite = prerequisiteService.createCourseCompetency(prerequisite, course); @@ -163,9 +161,7 @@ public ResponseEntity createPrerequisite(@PathVariable long course public ResponseEntity> createPrerequisite(@PathVariable Long courseId, @RequestBody List prerequisites) throws URISyntaxException { log.debug("REST request to create Prerequisites : {}", prerequisites); for (Prerequisite prerequisite : prerequisites) { - if (prerequisite.getId() != null || prerequisite.getTitle() == null || prerequisite.getTitle().trim().isEmpty()) { - throw new BadRequestException(); - } + checkPrerequisitesAttributesForCreation(prerequisite); } var course = courseRepository.findWithEagerCompetenciesAndPrerequisitesByIdElseThrow(courseId); @@ -177,25 +173,31 @@ public ResponseEntity> createPrerequisite(@PathVariable Long /** * POST courses/:courseId/prerequisites/import : imports a new prerequisite. * - * @param courseId the id of the course to which the prerequisite should be imported to - * @param prerequisiteId the id of the prerequisite that should be imported + * @param courseId the id of the course to which the prerequisite should be imported to + * @param importOptions the options for the import * @return the ResponseEntity with status 201 (Created) and with body containing the imported prerequisite * @throws URISyntaxException if the Location URI syntax is incorrect */ @PostMapping("courses/{courseId}/prerequisites/import") @EnforceAtLeastInstructorInCourse - public ResponseEntity importPrerequisite(@PathVariable long courseId, @RequestBody long prerequisiteId) throws URISyntaxException { - log.info("REST request to import a prerequisite: {}", prerequisiteId); + public ResponseEntity importPrerequisite(@PathVariable long courseId, @RequestBody CompetencyImportOptionsDTO importOptions) throws URISyntaxException { + log.info("REST request to import a prerequisite: {}", importOptions.competencyIds()); + + if (importOptions.competencyIds() == null || importOptions.competencyIds().size() != 1) { + throw new BadRequestAlertException("Exactly one prerequisite must be imported", ENTITY_NAME, "noPrerequisite"); + } + long prerequisiteId = importOptions.competencyIds().iterator().next(); var course = courseRepository.findWithEagerCompetenciesAndPrerequisitesByIdElseThrow(courseId); - var prerequisiteToImport = courseCompetencyRepository.findByIdElseThrow(prerequisiteId); + var prerequisiteToImport = courseCompetencyRepository.findByIdWithExercisesAndLectureUnitsAndLecturesElseThrow(prerequisiteId); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, prerequisiteToImport.getCourse(), null); if (prerequisiteToImport.getCourse().getId().equals(courseId)) { throw new BadRequestAlertException("The prerequisite is already added to this course", ENTITY_NAME, "prerequisiteCycle"); } - Prerequisite createdPrerequisite = prerequisiteService.createPrerequisite(prerequisiteToImport, course); + Set createdPrerequisites = prerequisiteService.importPrerequisites(course, Set.of(prerequisiteToImport), importOptions); + Prerequisite createdPrerequisite = (Prerequisite) createdPrerequisites.iterator().next().competency(); return ResponseEntity.created(new URI("/api/courses/" + courseId + "/prerequisites/" + createdPrerequisite.getId())).body(createdPrerequisite); } @@ -203,21 +205,24 @@ public ResponseEntity importPrerequisite(@PathVariable long course /** * POST courses/:courseId/prerequisites/import/bulk : imports a number of prerequisites (and optionally their relations) into a course. * - * @param courseId the id of the course to which the prerequisites should be imported to - * @param prerequisiteIds the ids of the prerequisites that should be imported - * @param importRelations if relations should be imported as well + * @param courseId the id of the course to which the prerequisites should be imported to + * @param importOptions the options for the import * @return the ResponseEntity with status 201 (Created) and with body containing the imported prerequisites * @throws URISyntaxException if the Location URI syntax is incorrect */ @PostMapping("courses/{courseId}/prerequisites/import/bulk") @EnforceAtLeastEditorInCourse - public ResponseEntity> importPrerequisites(@PathVariable long courseId, @RequestBody Set prerequisiteIds, - @RequestParam(defaultValue = "false") boolean importRelations) throws URISyntaxException { - log.info("REST request to import prerequisites: {}", prerequisiteIds); + public ResponseEntity> importPrerequisites(@PathVariable long courseId, @RequestBody CompetencyImportOptionsDTO importOptions) + throws URISyntaxException { + log.info("REST request to import prerequisites: {}", importOptions.competencyIds()); + + if (importOptions.competencyIds() == null || importOptions.competencyIds().isEmpty()) { + throw new BadRequestAlertException("No prerequisites to import", ENTITY_NAME, "noPrerequisites"); + } var course = courseRepository.findWithEagerCompetenciesAndPrerequisitesByIdElseThrow(courseId); - List prerequisitesToImport = courseCompetencyRepository.findAllById(prerequisiteIds); + Set prerequisitesToImport = courseCompetencyRepository.findAllByIdWithExercisesAndLectureUnitsAndLecturesAndAttachments(importOptions.competencyIds()); User user = userRepository.getUserWithGroupsAndAuthorities(); prerequisitesToImport.forEach(prerequisiteToImport -> { @@ -227,48 +232,37 @@ public ResponseEntity> importPrerequisites(@P } }); - Set importedPrerequisites; - if (importRelations) { - importedPrerequisites = prerequisiteService.importPrerequisitesAndRelations(course, prerequisitesToImport); - } - else { - importedPrerequisites = prerequisiteService.importPrerequisites(course, prerequisitesToImport); - } + Set importedPrerequisites = prerequisiteService.importPrerequisites(course, prerequisitesToImport, importOptions); return ResponseEntity.created(new URI("/api/courses/" + courseId + "/prerequisites/")).body(importedPrerequisites); } /** - * POST courses/{courseId}/prerequisites/import-all/{sourceCourseId} : Imports all prerequisites of the source course (and optionally their relations) into another. + * POST courses/{courseId}/prerequisites/import-all : Imports all prerequisites of the source course (and optionally their relations) into another. * - * @param courseId the id of the course to import into - * @param sourceCourseId the id of the course to import from - * @param importRelations if relations should be imported as well + * @param courseId the id of the course to import into + * @param importOptions the options for the import * @return the ResponseEntity with status 201 (Created) and with body containing the imported prerequisites (and relations) * @throws URISyntaxException if the Location URI syntax is incorrect */ - @PostMapping("courses/{courseId}/prerequisites/import-all/{sourceCourseId}") + @PostMapping("courses/{courseId}/prerequisites/import-all") @EnforceAtLeastInstructorInCourse - public ResponseEntity> importAllPrerequisitesFromCourse(@PathVariable long courseId, @PathVariable long sourceCourseId, - @RequestParam(defaultValue = "false") boolean importRelations) throws URISyntaxException { - log.info("REST request to all prerequisites from course {} into course {}", sourceCourseId, courseId); + public ResponseEntity> importAllPrerequisitesFromCourse(@PathVariable long courseId, @RequestBody CompetencyImportOptionsDTO importOptions) + throws URISyntaxException { + log.info("REST request to all prerequisites from course {} into course {}", importOptions.sourceCourseId(), courseId); - if (courseId == sourceCourseId) { - throw new BadRequestAlertException("Cannot import from a course into itself", "Course", "courseCycle"); + if (importOptions.sourceCourseId().isEmpty()) { + throw new BadRequestAlertException("No source course specified", ENTITY_NAME, "noSourceCourse"); + } + else if (courseId == importOptions.sourceCourseId().get()) { + throw new BadRequestAlertException("Cannot import from a course into itself", ENTITY_NAME, "courseCycle"); } var targetCourse = courseRepository.findByIdElseThrow(courseId); - var sourceCourse = courseRepository.findByIdElseThrow(sourceCourseId); + var sourceCourse = courseRepository.findByIdElseThrow(importOptions.sourceCourseId().get()); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, sourceCourse, null); - var prerequisites = prerequisiteRepository.findAllForCourse(sourceCourse.getId()); - Set importedPrerequisites; - - if (importRelations) { - importedPrerequisites = prerequisiteService.importPrerequisitesAndRelations(targetCourse, prerequisites); - } - else { - importedPrerequisites = prerequisiteService.importPrerequisites(targetCourse, prerequisites); - } + var prerequisites = prerequisiteRepository.findAllForCourseWithExercisesAndLectureUnitsAndLecturesAndAttachments(sourceCourse.getId()); + Set importedPrerequisites = prerequisiteService.importPrerequisites(targetCourse, prerequisites, importOptions); return ResponseEntity.created(new URI("/api/courses/" + courseId + "/prerequisites/")).body(importedPrerequisites); } @@ -304,9 +298,8 @@ public ResponseEntity> importStandardizedPrere @EnforceAtLeastInstructorInCourse public ResponseEntity updatePrerequisite(@PathVariable long courseId, @RequestBody Prerequisite prerequisite) { log.debug("REST request to update Prerequisite : {}", prerequisite); - if (prerequisite.getId() == null) { - throw new BadRequestException(); - } + checkPrerequisitesAttributesForUpdate(prerequisite); + var course = courseRepository.findByIdElseThrow(courseId); var existingPrerequisite = prerequisiteRepository.findByIdWithLectureUnitsElseThrow(prerequisite.getId()); checkCourseForPrerequisite(course, existingPrerequisite); @@ -338,6 +331,26 @@ public ResponseEntity deletePrerequisite(@PathVariable long prerequisiteId return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, prerequisite.getTitle())).build(); } + private void checkPrerequisitesAttributesForCreation(Prerequisite prerequisite) { + if (prerequisite.getId() != null) { + throw new BadRequestAlertException("A new prerequiste should not have an id", ENTITY_NAME, "existingPrerequisiteId"); + } + checkPrerequisitesAttributes(prerequisite); + } + + private void checkPrerequisitesAttributesForUpdate(Prerequisite prerequisite) { + if (prerequisite.getId() == null) { + throw new BadRequestAlertException("An updated prerequiste should have an id", ENTITY_NAME, "missingPrerequisiteId"); + } + checkPrerequisitesAttributes(prerequisite); + } + + private void checkPrerequisitesAttributes(Prerequisite prerequisite) { + if (prerequisite.getTitle() == null || prerequisite.getTitle().trim().isEmpty() || prerequisite.getMasteryThreshold() < 1 || prerequisite.getMasteryThreshold() > 100) { + throw new BadRequestAlertException("The attributes of the competency are invalid!", ENTITY_NAME, "invalidPrerequisiteAttributes"); + } + } + /** * Checks if the prerequisite matches the course. * diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java index f82f7aa7a36f..7534de04e3bf 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java @@ -294,6 +294,7 @@ private void processBuild(BuildJobQueueItem buildJob) { CompletableFuture futureResult = buildJobManagementService.executeBuildJob(buildJob); futureResult.thenAccept(buildResult -> { + log.debug("Build job completed: {}", buildJob); JobTimingInfo jobTimingInfo = new JobTimingInfo(buildJob.jobTimingInfo().submissionDate(), buildJob.jobTimingInfo().buildStartDate(), ZonedDateTime.now()); BuildJobQueueItem finishedJob = new BuildJobQueueItem(buildJob.id(), buildJob.name(), buildJob.buildAgentAddress(), buildJob.participationId(), buildJob.courseId(), @@ -316,6 +317,8 @@ private void processBuild(BuildJobQueueItem buildJob) { }); futureResult.exceptionally(ex -> { + log.debug("Build job completed with exception: {}", buildJob, ex); + ZonedDateTime completionDate = ZonedDateTime.now(); BuildJobQueueItem job; @@ -364,6 +367,7 @@ public class QueuedBuildJobItemListener implements ItemListener event) { log.debug("CIBuildJobQueueItem added to queue: {}", event.getItem()); + log.debug("Current queued items: {}", queue.size()); checkAvailabilityAndProcessNextBuild(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/notification/SingleUserNotificationFactory.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/notification/SingleUserNotificationFactory.java index f143f959f89f..71dc52ae4e93 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/notification/SingleUserNotificationFactory.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/notification/SingleUserNotificationFactory.java @@ -434,7 +434,7 @@ public static String[] createPlaceholdersForUserGroupChat(String courseTitle, St } @NotificationPlaceholderCreator(values = { CONVERSATION_ADD_USER_CHANNEL, CONVERSATION_REMOVE_USER_CHANNEL, CONVERSATION_DELETE_CHANNEL }) - public static String[] createPlaceholdersForUserChannel(String courseTitle, String channelName, String responsibleForUserName) { - return new String[] { courseTitle, channelName, responsibleForUserName }; + public static String[] createPlaceholdersForUserChannel(String courseTitle, String conversationName, String responsibleForUserName) { + return new String[] { courseTitle, conversationName, responsibleForUserName }; } } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/ConversationNotificationService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/ConversationNotificationService.java index 1e13dce59c5a..d009074927b2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/ConversationNotificationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/notifications/ConversationNotificationService.java @@ -18,6 +18,7 @@ import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; import de.tum.cit.aet.artemis.communication.domain.conversation.GroupChat; +import de.tum.cit.aet.artemis.communication.domain.conversation.OneToOneChat; import de.tum.cit.aet.artemis.communication.domain.notification.ConversationNotification; import de.tum.cit.aet.artemis.communication.domain.notification.NotificationPlaceholderCreator; import de.tum.cit.aet.artemis.communication.domain.notification.SingleUserNotification; @@ -57,37 +58,39 @@ public ConversationNotificationService(ConversationNotificationRepository conver * @return the created notification */ public ConversationNotification createNotification(Post createdMessage, Conversation conversation, Course course, Set mentionedUsers) { - String notificationText; - String[] placeholders; NotificationType notificationType = CONVERSATION_NEW_MESSAGE; String conversationName = conversation.getHumanReadableNameForReceiver(createdMessage.getAuthor()); + String conversationType; + String notificationText; // add channel/groupChat/oneToOneChat string to placeholders for notification to distinguish in mobile client - if (conversation instanceof Channel channel) { - notificationText = NEW_MESSAGE_CHANNEL_TEXT; - placeholders = createPlaceholdersNewMessageChannelText(course.getTitle(), createdMessage.getContent(), createdMessage.getCreationDate().toString(), - createdMessage.getAuthor().getName(), conversationName, "channel"); - notificationType = getNotificationTypeForChannel(channel); - } - else if (conversation instanceof GroupChat) { - notificationText = NEW_MESSAGE_GROUP_CHAT_TEXT; - placeholders = createPlaceholdersNewMessageChannelText(course.getTitle(), createdMessage.getContent(), createdMessage.getCreationDate().toString(), - createdMessage.getAuthor().getName(), conversationName, "groupChat"); - } - else { - notificationText = NEW_MESSAGE_DIRECT_TEXT; - placeholders = createPlaceholdersNewMessageChannelText(course.getTitle(), createdMessage.getContent(), createdMessage.getCreationDate().toString(), - createdMessage.getAuthor().getName(), conversationName, "oneToOneChat"); + switch (conversation) { + case Channel channel -> { + notificationText = NEW_MESSAGE_CHANNEL_TEXT; + conversationType = "channel"; + notificationType = getNotificationTypeForChannel(channel); + } + case GroupChat ignored -> { + notificationText = NEW_MESSAGE_GROUP_CHAT_TEXT; + conversationType = "groupChat"; + } + case OneToOneChat ignored -> { + notificationText = NEW_MESSAGE_DIRECT_TEXT; + conversationType = "oneToOneChat"; + } + default -> throw new IllegalStateException("Unexpected value: " + conversation); } + String[] placeholders = createPlaceholdersNewMessageChannelText(course.getTitle(), createdMessage.getContent(), createdMessage.getCreationDate().toString(), + conversationName, createdMessage.getAuthor().getName(), conversationType); ConversationNotification notification = createConversationMessageNotification(course.getId(), createdMessage, notificationType, notificationText, true, placeholders); save(notification, mentionedUsers, placeholders); return notification; } @NotificationPlaceholderCreator(values = { CONVERSATION_NEW_MESSAGE }) - public static String[] createPlaceholdersNewMessageChannelText(String courseTitle, String messageContent, String messageCreationDate, String channelName, String authorName, - String conversationType) { - return new String[] { courseTitle, messageContent, messageCreationDate, channelName, authorName, conversationType }; + public static String[] createPlaceholdersNewMessageChannelText(String courseTitle, String messageContent, String messageCreationDate, String conversationName, + String authorName, String conversationType) { + return new String[] { courseTitle, messageContent, messageCreationDate, conversationName, authorName, conversationType }; } private void save(ConversationNotification notification, Set mentionedUsers, String[] placeHolders) { diff --git a/src/main/java/de/tum/cit/aet/artemis/core/exception/NoUniqueQueryException.java b/src/main/java/de/tum/cit/aet/artemis/core/exception/NoUniqueQueryException.java new file mode 100644 index 000000000000..f76fc32879ed --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/exception/NoUniqueQueryException.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.artemis.core.exception; + +/** + * Checked exception in case a query does not return a unique result, so calling methods must handle this case. + */ +public class NoUniqueQueryException extends Exception { + + public NoUniqueQueryException(String message) { + super(message); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamImportService.java b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamImportService.java index 92430d5ef7f1..58fcf03cc97a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamImportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamImportService.java @@ -291,7 +291,7 @@ private void addExercisesToExerciseGroup(ExerciseGroup exerciseGroupToCopy, Exer Optional exerciseCopied = switch (exerciseToCopy.getExerciseType()) { case MODELING -> { final Optional optionalOriginalModellingExercise = modelingExerciseRepository - .findByIdWithExampleSubmissionsAndResultsAndPlagiarismDetectionConfig(exerciseToCopy.getId()); + .findByIdWithExampleSubmissionsAndResultsAndPlagiarismDetectionConfigAndGradingCriteria(exerciseToCopy.getId()); // We do not want to abort the whole exam import process, we only skip the relevant exercise if (optionalOriginalModellingExercise.isEmpty()) { yield Optional.empty(); diff --git a/src/main/java/de/tum/cit/aet/artemis/fileupload/repository/FileUploadExerciseRepository.java b/src/main/java/de/tum/cit/aet/artemis/fileupload/repository/FileUploadExerciseRepository.java index 4a0a53761fe0..376a70db4c7e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/fileupload/repository/FileUploadExerciseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/fileupload/repository/FileUploadExerciseRepository.java @@ -5,9 +5,11 @@ import java.util.List; import java.util.Optional; +import java.util.Set; import jakarta.validation.constraints.NotNull; +import org.hibernate.NonUniqueResultException; import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -15,6 +17,7 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import de.tum.cit.aet.artemis.core.exception.NoUniqueQueryException; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; import de.tum.cit.aet.artemis.fileupload.domain.FileUploadExercise; @@ -38,8 +41,41 @@ public interface FileUploadExerciseRepository extends ArtemisJpaRepository findWithEagerTeamAssignmentConfigAndCategoriesAndCompetenciesById(Long exerciseId); + @Query(""" + SELECT f + FROM FileUploadExercise f + LEFT JOIN FETCH f.competencies + WHERE f.title = :title + AND f.course.id = :courseId + """) + Set findAllWithCompetenciesByTitleAndCourseId(@Param("title") String title, @Param("courseId") long courseId) throws NonUniqueResultException; + + /** + * Finds a file upload exercise by its title and course id and throws a NoUniqueQueryException if multiple exercises are found. + * + * @param title the title of the exercise + * @param courseId the id of the course + * @return the exercise with the given title and course id + * @throws NoUniqueQueryException if multiple exercises are found with the same title + */ + default Optional findUniqueWithCompetenciesByTitleAndCourseId(String title, long courseId) throws NoUniqueQueryException { + Set allExercises = findAllWithCompetenciesByTitleAndCourseId(title, courseId); + if (allExercises.size() > 1) { + throw new NoUniqueQueryException("Found multiple exercises with title " + title + " in course with id " + courseId); + } + return allExercises.stream().findFirst(); + } + @NotNull default FileUploadExercise findWithEagerCompetenciesByIdElseThrow(Long exerciseId) { return getValueElseThrow(findWithEagerCompetenciesById(exerciseId), exerciseId); } + + @EntityGraph(type = LOAD, attributePaths = { "gradingCriteria" }) + Optional findWithGradingCriteriaById(Long exerciseId); + + @NotNull + default FileUploadExercise findWithGradingCriteriaByIdElseThrow(Long exerciseId) { + return getValueElseThrow(findWithGradingCriteriaById(exerciseId), exerciseId); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java index 7ab7df9d479d..621216563727 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureRepository.java @@ -17,6 +17,7 @@ import org.springframework.stereotype.Repository; import de.tum.cit.aet.artemis.core.dto.CourseContentCount; +import de.tum.cit.aet.artemis.core.exception.NoUniqueQueryException; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; import de.tum.cit.aet.artemis.lecture.domain.Lecture; @@ -100,6 +101,30 @@ public interface LectureRepository extends ArtemisJpaRepository { """) Optional findByIdWithLectureUnitsAndSlidesAndAttachments(@Param("lectureId") long lectureId); + @Query(""" + SELECT lecture + FROM Lecture lecture + LEFT JOIN FETCH lecture.lectureUnits + WHERE lecture.title = :title AND lecture.course.id = :courseId + """) + Set findAllByTitleAndCourseIdWithLectureUnits(@Param("title") String title, @Param("courseId") long courseId); + + /** + * Finds a lecture by its title and course id and throws a NoUniqueQueryException if multiple lectures are found. + * + * @param title the title of the lecture + * @param courseId the id of the course + * @return the lecture with the given title and course id + * @throws NoUniqueQueryException if multiple lectures are found with the same title + */ + default Optional findUniqueByTitleAndCourseIdWithLectureUnitsElseThrow(String title, long courseId) throws NoUniqueQueryException { + Set allLectures = findAllByTitleAndCourseIdWithLectureUnits(title, courseId); + if (allLectures.size() > 1) { + throw new NoUniqueQueryException("Found multiple lectures with title " + title + " in course with id " + courseId); + } + return allLectures.stream().findFirst(); + } + @SuppressWarnings("PMD.MethodNamingConventions") Page findByTitleIgnoreCaseContainingOrCourse_TitleIgnoreCaseContaining(String partialTitle, String partialCourseTitle, Pageable pageable); diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java index fceab652c24d..ee29df83380a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/repository/LectureUnitRepository.java @@ -5,6 +5,7 @@ import java.util.Optional; import java.util.Set; +import org.hibernate.NonUniqueResultException; import org.springframework.context.annotation.Profile; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -61,6 +62,27 @@ public interface LectureUnitRepository extends ArtemisJpaRepository findByIdWithCompletedUsers(@Param("lectureUnitId") long lectureUnitId); + /** + * Finds a lecture unit by name, lecture title and course id. Currently, name duplicates are allowed but this method throws an exception if multiple lecture units with the + * same name are found. + * + * @param name the name of the lecture unit + * @param lectureTitle the title of the lecture containing the lecture unit + * @param courseId the id of the course containing the lecture + * @return the lecture unit with the given name, lecture title and course id + * @throws NonUniqueResultException if multiple lecture units with the same name in the same lecture are found + */ + @Query(""" + SELECT lu + FROM LectureUnit lu + LEFT JOIN FETCH lu.competencies + WHERE lu.name = :name + AND lu.lecture.title = :lectureTitle + AND lu.lecture.course.id = :courseId + """) + Optional findByNameAndLectureTitleAndCourseIdWithCompetencies(@Param("name") String name, @Param("lectureTitle") String lectureTitle, + @Param("courseId") long courseId) throws NonUniqueResultException; + default LectureUnit findByIdWithCompletedUsersElseThrow(long lectureUnitId) { return getValueElseThrow(findByIdWithCompletedUsers(lectureUnitId), lectureUnitId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureImportService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureImportService.java index 5eca29ad0bc1..4a3e880640de 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureImportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureImportService.java @@ -2,11 +2,8 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; -import java.net.URI; -import java.nio.file.Path; import java.util.ArrayList; import java.util.HashSet; -import java.util.List; import java.util.Optional; import java.util.Set; @@ -16,22 +13,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.core.domain.Course; -import de.tum.cit.aet.artemis.core.service.FilePathService; -import de.tum.cit.aet.artemis.core.service.FileService; -import de.tum.cit.aet.artemis.iris.repository.IrisSettingsRepository; -import de.tum.cit.aet.artemis.iris.service.pyris.PyrisWebhookService; import de.tum.cit.aet.artemis.lecture.domain.Attachment; -import de.tum.cit.aet.artemis.lecture.domain.AttachmentUnit; -import de.tum.cit.aet.artemis.lecture.domain.ExerciseUnit; import de.tum.cit.aet.artemis.lecture.domain.Lecture; -import de.tum.cit.aet.artemis.lecture.domain.LectureUnit; -import de.tum.cit.aet.artemis.lecture.domain.OnlineUnit; -import de.tum.cit.aet.artemis.lecture.domain.TextUnit; -import de.tum.cit.aet.artemis.lecture.domain.VideoUnit; import de.tum.cit.aet.artemis.lecture.repository.AttachmentRepository; import de.tum.cit.aet.artemis.lecture.repository.LectureRepository; -import de.tum.cit.aet.artemis.lecture.repository.LectureUnitRepository; @Profile(PROFILE_CORE) @Service @@ -41,39 +28,30 @@ public class LectureImportService { private final LectureRepository lectureRepository; - private final LectureUnitRepository lectureUnitRepository; - private final AttachmentRepository attachmentRepository; - private final Optional pyrisWebhookService; - - private final FileService fileService; - - private final SlideSplitterService slideSplitterService; + private final LectureUnitImportService lectureUnitImportService; - private final Optional irisSettingsRepository; + private final ChannelService channelService; - public LectureImportService(LectureRepository lectureRepository, LectureUnitRepository lectureUnitRepository, AttachmentRepository attachmentRepository, - Optional pyrisWebhookService, FileService fileService, SlideSplitterService slideSplitterService, - Optional irisSettingsRepository) { + public LectureImportService(LectureRepository lectureRepository, AttachmentRepository attachmentRepository, LectureUnitImportService lectureUnitImportService, + ChannelService channelService) { this.lectureRepository = lectureRepository; - this.lectureUnitRepository = lectureUnitRepository; this.attachmentRepository = attachmentRepository; - this.pyrisWebhookService = pyrisWebhookService; - this.fileService = fileService; - this.slideSplitterService = slideSplitterService; - this.irisSettingsRepository = irisSettingsRepository; + this.lectureUnitImportService = lectureUnitImportService; + this.channelService = channelService; } /** * Import the {@code importedLecture} including its lecture units and attachments to the {@code course} * - * @param importedLecture The lecture to be imported - * @param course The course to import to + * @param importedLecture The lecture to be imported + * @param course The course to import to + * @param importLectureUnits Whether to import the lecture units of the lecture * @return The lecture in the new course */ @Transactional // Required to circumvent errors with ordered collection of lecture units - public Lecture importLecture(final Lecture importedLecture, final Course course) { + public Lecture importLecture(final Lecture importedLecture, final Course course, boolean importLectureUnits) { log.debug("Creating a new Lecture based on lecture {}", importedLecture); // Copy the lecture itself to the new course @@ -83,127 +61,32 @@ public Lecture importLecture(final Lecture importedLecture, final Course course) lecture.setStartDate(importedLecture.getStartDate()); lecture.setEndDate(importedLecture.getEndDate()); lecture.setVisibleDate(importedLecture.getVisibleDate()); + lecture.setCourse(course); lecture = lectureRepository.save(lecture); - course.addLectures(lecture); - - log.debug("Importing lecture units from lecture"); - List lectureUnits = new ArrayList<>(); - for (LectureUnit lectureUnit : importedLecture.getLectureUnits()) { - LectureUnit clonedLectureUnit = cloneLectureUnit(lectureUnit, lecture); - if (clonedLectureUnit != null) { - clonedLectureUnit.setLecture(lecture); - lectureUnits.add(clonedLectureUnit); - } + + if (importLectureUnits) { + lectureUnitImportService.importLectureUnits(importedLecture, lecture); + } + else { + importedLecture.setLectureUnits(new ArrayList<>()); } - lecture.setLectureUnits(lectureUnits); - lectureUnitRepository.saveAll(lectureUnits); log.debug("Importing attachments from lecture"); Set attachments = new HashSet<>(); for (Attachment attachment : importedLecture.getAttachments()) { - Attachment clonedAttachment = cloneAttachment(lecture.getId(), attachment); + Attachment clonedAttachment = lectureUnitImportService.importAttachment(lecture.getId(), attachment); clonedAttachment.setLecture(lecture); attachments.add(clonedAttachment); } lecture.setAttachments(attachments); attachmentRepository.saveAll(attachments); - // Send lectures to pyris - if (pyrisWebhookService.isPresent() && irisSettingsRepository.isPresent()) { - pyrisWebhookService.get().autoUpdateAttachmentUnitsInPyris(lecture.getCourse().getId(), - lectureUnits.stream().filter(lectureUnit -> lectureUnit instanceof AttachmentUnit).map(lectureUnit -> (AttachmentUnit) lectureUnit).toList()); - } // Save again to establish the ordered list relationship - return lectureRepository.save(lecture); - } + Lecture savedLecture = lectureRepository.save(lecture); - /** - * This helper function clones the {@code importedLectureUnit} and returns it - * - * @param importedLectureUnit The original lecture unit to be copied - * @param newLecture The new lecture to which the lecture units are appended - * @return The cloned lecture unit - */ - private LectureUnit cloneLectureUnit(final LectureUnit importedLectureUnit, final Lecture newLecture) { - log.debug("Creating a new LectureUnit from lecture unit {}", importedLectureUnit); - - if (importedLectureUnit instanceof TextUnit importedTextUnit) { - TextUnit textUnit = new TextUnit(); - textUnit.setName(importedTextUnit.getName()); - textUnit.setReleaseDate(importedTextUnit.getReleaseDate()); - textUnit.setContent(importedTextUnit.getContent()); - return textUnit; - } - else if (importedLectureUnit instanceof VideoUnit importedVideoUnit) { - VideoUnit videoUnit = new VideoUnit(); - videoUnit.setName(importedVideoUnit.getName()); - videoUnit.setReleaseDate(importedVideoUnit.getReleaseDate()); - videoUnit.setDescription(importedVideoUnit.getDescription()); - videoUnit.setSource(importedVideoUnit.getSource()); - return videoUnit; - } - else if (importedLectureUnit instanceof AttachmentUnit importedAttachmentUnit) { - // Create and save the attachment unit, then the attachment itself, as the id is needed for file handling - AttachmentUnit attachmentUnit = new AttachmentUnit(); - attachmentUnit.setDescription(importedAttachmentUnit.getDescription()); - attachmentUnit.setLecture(newLecture); - lectureUnitRepository.save(attachmentUnit); - - Attachment attachment = cloneAttachment(attachmentUnit.getId(), importedAttachmentUnit.getAttachment()); - attachment.setAttachmentUnit(attachmentUnit); - attachmentRepository.save(attachment); - if (attachment.getLink().endsWith(".pdf")) { - slideSplitterService.splitAttachmentUnitIntoSingleSlides(attachmentUnit); - } - attachmentUnit.setAttachment(attachment); - return attachmentUnit; - } - else if (importedLectureUnit instanceof OnlineUnit importedOnlineUnit) { - OnlineUnit onlineUnit = new OnlineUnit(); - onlineUnit.setName(importedOnlineUnit.getName()); - onlineUnit.setReleaseDate(importedOnlineUnit.getReleaseDate()); - onlineUnit.setDescription(importedOnlineUnit.getDescription()); - onlineUnit.setSource(importedOnlineUnit.getSource()); - - return onlineUnit; - } - else if (importedLectureUnit instanceof ExerciseUnit) { - // TODO: Import exercises and link them to the exerciseUnit - // We have a dedicated exercise import system, so this is left out for now - return null; - } - return null; - } + channelService.createLectureChannel(savedLecture, Optional.empty()); - /** - * This helper function clones the {@code importedAttachment} (and duplicates its file) and returns it - * - * @param entityId The id of the new entity to which the attachment is linked - * @param importedAttachment The original attachment to be copied - * @return The cloned attachment with the file also duplicated to the temp directory on disk - */ - private Attachment cloneAttachment(Long entityId, final Attachment importedAttachment) { - log.debug("Creating a new Attachment from attachment {}", importedAttachment); - - Attachment attachment = new Attachment(); - attachment.setName(importedAttachment.getName()); - attachment.setUploadDate(importedAttachment.getUploadDate()); - attachment.setReleaseDate(importedAttachment.getReleaseDate()); - attachment.setVersion(importedAttachment.getVersion()); - attachment.setAttachmentType(importedAttachment.getAttachmentType()); - - Path oldPath = FilePathService.actualPathForPublicPathOrThrow(URI.create(importedAttachment.getLink())); - Path newPath; - if (oldPath.toString().contains("/attachment-unit/")) { - newPath = FilePathService.getAttachmentUnitFilePath().resolve(entityId.toString()); - } - else { - newPath = FilePathService.getLectureAttachmentFilePath().resolve(entityId.toString()); - } - log.debug("Copying attachment file from {} to {}", oldPath, newPath); - Path savePath = fileService.copyExistingFileToTarget(oldPath, newPath); - attachment.setLink(FilePathService.publicPathForActualPathOrThrow(savePath, entityId).toString()); - return attachment; + return savedLecture; } } diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitImportService.java b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitImportService.java new file mode 100644 index 000000000000..0c537eda34bc --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/service/LectureUnitImportService.java @@ -0,0 +1,175 @@ +package de.tum.cit.aet.artemis.lecture.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.net.URI; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.core.service.FilePathService; +import de.tum.cit.aet.artemis.core.service.FileService; +import de.tum.cit.aet.artemis.iris.repository.IrisSettingsRepository; +import de.tum.cit.aet.artemis.iris.service.pyris.PyrisWebhookService; +import de.tum.cit.aet.artemis.lecture.domain.Attachment; +import de.tum.cit.aet.artemis.lecture.domain.AttachmentUnit; +import de.tum.cit.aet.artemis.lecture.domain.ExerciseUnit; +import de.tum.cit.aet.artemis.lecture.domain.Lecture; +import de.tum.cit.aet.artemis.lecture.domain.LectureUnit; +import de.tum.cit.aet.artemis.lecture.domain.OnlineUnit; +import de.tum.cit.aet.artemis.lecture.domain.TextUnit; +import de.tum.cit.aet.artemis.lecture.domain.VideoUnit; +import de.tum.cit.aet.artemis.lecture.repository.AttachmentRepository; +import de.tum.cit.aet.artemis.lecture.repository.LectureUnitRepository; + +@Profile(PROFILE_CORE) +@Service +public class LectureUnitImportService { + + private static final Logger log = LoggerFactory.getLogger(LectureUnitImportService.class); + + private final LectureUnitRepository lectureUnitRepository; + + private final AttachmentRepository attachmentRepository; + + private final FileService fileService; + + private final SlideSplitterService slideSplitterService; + + private final Optional pyrisWebhookService; + + private final Optional irisSettingsRepository; + + public LectureUnitImportService(LectureUnitRepository lectureUnitRepository, AttachmentRepository attachmentRepository, FileService fileService, + SlideSplitterService slideSplitterService, Optional pyrisWebhookService, Optional irisSettingsRepository) { + this.lectureUnitRepository = lectureUnitRepository; + this.attachmentRepository = attachmentRepository; + this.fileService = fileService; + this.slideSplitterService = slideSplitterService; + this.pyrisWebhookService = pyrisWebhookService; + this.irisSettingsRepository = irisSettingsRepository; + } + + /** + * This function imports the lecture units from the {@code importedLecture} and appends them to the {@code lecture} + * + * @param importedLecture The original lecture to be copied + * @param lecture The new lecture to which the lecture units are appended + */ + public void importLectureUnits(Lecture importedLecture, Lecture lecture) { + log.debug("Importing lecture units from lecture with Id {}", importedLecture.getId()); + List lectureUnits = new ArrayList<>(); + for (LectureUnit lectureUnit : importedLecture.getLectureUnits()) { + LectureUnit clonedLectureUnit = importLectureUnit(lectureUnit); + if (clonedLectureUnit != null) { + clonedLectureUnit.setLecture(lecture); + lectureUnits.add(clonedLectureUnit); + } + } + lecture.setLectureUnits(lectureUnits); + lectureUnitRepository.saveAll(lectureUnits); + + // Send lectures to pyris + if (pyrisWebhookService.isPresent() && irisSettingsRepository.isPresent()) { + pyrisWebhookService.get().autoUpdateAttachmentUnitsInPyris(lecture.getCourse().getId(), + lectureUnits.stream().filter(lectureUnit -> lectureUnit instanceof AttachmentUnit).map(lectureUnit -> (AttachmentUnit) lectureUnit).toList()); + } + } + + /** + * This function imports the {@code importedLectureUnit} and returns it + * + * @param importedLectureUnit The original lecture unit to be copied + * @return The imported lecture unit + */ + public LectureUnit importLectureUnit(final LectureUnit importedLectureUnit) { + log.debug("Creating a new LectureUnit from lecture unit {}", importedLectureUnit); + + switch (importedLectureUnit) { + case TextUnit importedTextUnit -> { + TextUnit textUnit = new TextUnit(); + textUnit.setName(importedTextUnit.getName()); + textUnit.setReleaseDate(importedTextUnit.getReleaseDate()); + textUnit.setContent(importedTextUnit.getContent()); + + return lectureUnitRepository.save(textUnit); + } + case VideoUnit importedVideoUnit -> { + VideoUnit videoUnit = new VideoUnit(); + videoUnit.setName(importedVideoUnit.getName()); + videoUnit.setReleaseDate(importedVideoUnit.getReleaseDate()); + videoUnit.setDescription(importedVideoUnit.getDescription()); + videoUnit.setSource(importedVideoUnit.getSource()); + + return lectureUnitRepository.save(videoUnit); + } + case AttachmentUnit importedAttachmentUnit -> { + // Create and save the attachment unit, then the attachment itself, as the id is needed for file handling + AttachmentUnit attachmentUnit = new AttachmentUnit(); + attachmentUnit.setDescription(importedAttachmentUnit.getDescription()); + attachmentUnit = lectureUnitRepository.save(attachmentUnit); + + Attachment attachment = importAttachment(attachmentUnit.getId(), importedAttachmentUnit.getAttachment()); + attachment.setAttachmentUnit(attachmentUnit); + attachmentRepository.save(attachment); + if (attachment.getLink().endsWith(".pdf")) { + slideSplitterService.splitAttachmentUnitIntoSingleSlides(attachmentUnit); + } + attachmentUnit.setAttachment(attachment); + return attachmentUnit; + } + case OnlineUnit importedOnlineUnit -> { + OnlineUnit onlineUnit = new OnlineUnit(); + onlineUnit.setName(importedOnlineUnit.getName()); + onlineUnit.setReleaseDate(importedOnlineUnit.getReleaseDate()); + onlineUnit.setDescription(importedOnlineUnit.getDescription()); + onlineUnit.setSource(importedOnlineUnit.getSource()); + + return lectureUnitRepository.save(onlineUnit); + } + case ExerciseUnit ignored -> { + // TODO: Import exercises and link them to the exerciseUnit + // We have a dedicated exercise import system, so this is left out for now + return null; + } + default -> throw new IllegalArgumentException("Unknown lecture unit type: " + importedLectureUnit.getClass()); + } + } + + /** + * This function imports the {@code importedAttachment}, and duplicates its file and returns it + * + * @param entityId The id of the new entity to which the attachment is linked + * @param importedAttachment The original attachment to be copied + * @return The imported attachment with the file also duplicated to the temp directory on disk + */ + public Attachment importAttachment(Long entityId, final Attachment importedAttachment) { + log.debug("Creating a new Attachment from attachment {}", importedAttachment); + + Attachment attachment = new Attachment(); + attachment.setName(importedAttachment.getName()); + attachment.setUploadDate(importedAttachment.getUploadDate()); + attachment.setReleaseDate(importedAttachment.getReleaseDate()); + attachment.setVersion(importedAttachment.getVersion()); + attachment.setAttachmentType(importedAttachment.getAttachmentType()); + + Path oldPath = FilePathService.actualPathForPublicPathOrThrow(URI.create(importedAttachment.getLink())); + Path newPath; + if (oldPath.toString().contains("/attachment-unit/")) { + newPath = FilePathService.getAttachmentUnitFilePath().resolve(entityId.toString()); + } + else { + newPath = FilePathService.getLectureAttachmentFilePath().resolve(entityId.toString()); + } + log.debug("Copying attachment file from {} to {}", oldPath, newPath); + Path savePath = fileService.copyExistingFileToTarget(oldPath, newPath); + attachment.setLink(FilePathService.publicPathForActualPathOrThrow(savePath, entityId).toString()); + return attachment; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java index a69bc110de6f..54ae76bf3bad 100644 --- a/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/lecture/web/LectureResource.java @@ -255,8 +255,7 @@ public ResponseEntity importLecture(@PathVariable long sourceLectureId, authCheckService.checkHasAtLeastRoleForLectureElseThrow(Role.EDITOR, sourceLecture, user); authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, destinationCourse, user); - final var savedLecture = lectureImportService.importLecture(sourceLecture, destinationCourse); - channelService.createLectureChannel(savedLecture, Optional.empty()); + final var savedLecture = lectureImportService.importLecture(sourceLecture, destinationCourse, true); return ResponseEntity.created(new URI("/api/lectures/" + savedLecture.getId())).body(savedLecture); } diff --git a/src/main/java/de/tum/cit/aet/artemis/modeling/repository/ModelingExerciseRepository.java b/src/main/java/de/tum/cit/aet/artemis/modeling/repository/ModelingExerciseRepository.java index f6e80c0c17b2..626bb3c86694 100644 --- a/src/main/java/de/tum/cit/aet/artemis/modeling/repository/ModelingExerciseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/modeling/repository/ModelingExerciseRepository.java @@ -6,6 +6,7 @@ import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; +import java.util.Set; import jakarta.validation.constraints.NotNull; @@ -16,6 +17,7 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import de.tum.cit.aet.artemis.core.exception.NoUniqueQueryException; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; import de.tum.cit.aet.artemis.modeling.domain.ModelingExercise; @@ -54,9 +56,10 @@ public interface ModelingExerciseRepository extends ArtemisJpaRepository findByIdWithExampleSubmissionsAndResultsAndPlagiarismDetectionConfig(@Param("exerciseId") Long exerciseId); + Optional findByIdWithExampleSubmissionsAndResultsAndPlagiarismDetectionConfigAndGradingCriteria(@Param("exerciseId") Long exerciseId); /** * Get all modeling exercises that need to be scheduled: Those must satisfy one of the following requirements: @@ -94,6 +97,31 @@ public interface ModelingExerciseRepository extends ArtemisJpaRepository findWithStudentParticipationsSubmissionsResultsById(Long exerciseId); + @Query(""" + SELECT m + FROM ModelingExercise m + LEFT JOIN FETCH m.competencies + WHERE m.title = :title + AND m.course.id = :courseId + """) + Set findAllWithCompetenciesByTitleAndCourseId(@Param("title") String title, @Param("courseId") long courseId); + + /** + * Finds a modeling exercise by its title and course id and throws a NoUniqueQueryException if multiple exercises are found. + * + * @param title the title of the exercise + * @param courseId the id of the course + * @return the exercise with the given title and course id + * @throws NoUniqueQueryException if multiple exercises are found with the same title + */ + default Optional findUniqueWithCompetenciesByTitleAndCourseId(String title, long courseId) throws NoUniqueQueryException { + Set allExercises = findAllWithCompetenciesByTitleAndCourseId(title, courseId); + if (allExercises.size() > 1) { + throw new NoUniqueQueryException("Found multiple exercises with title " + title + " in course with id " + courseId); + } + return allExercises.stream().findFirst(); + } + @NotNull default ModelingExercise findWithEagerExampleSubmissionsAndCompetenciesByIdElseThrow(long exerciseId) { return getValueElseThrow(findWithEagerExampleSubmissionsAndCompetenciesById(exerciseId), exerciseId); @@ -106,7 +134,7 @@ default ModelingExercise findWithEagerExampleSubmissionsAndCompetenciesAndPlagia @NotNull default ModelingExercise findByIdWithExampleSubmissionsAndResultsAndPlagiarismDetectionConfigElseThrow(long exerciseId) { - return getValueElseThrow(findByIdWithExampleSubmissionsAndResultsAndPlagiarismDetectionConfig(exerciseId), exerciseId); + return getValueElseThrow(findByIdWithExampleSubmissionsAndResultsAndPlagiarismDetectionConfigAndGradingCriteria(exerciseId), exerciseId); } @NotNull diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java index 370c191e0ac0..ab64ca6e53a8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java @@ -226,26 +226,6 @@ default ProgrammingExercise findOneByProjectKeyOrThrow(String projectKey, boolea """) List findAllByRecentExamEndDate(@Param("endDate1") ZonedDateTime endDate1, @Param("endDate2") ZonedDateTime endDate2); - @Query(""" - SELECT DISTINCT pe - FROM ProgrammingExercise pe - LEFT JOIN FETCH pe.studentParticipations - WHERE pe.dueDate IS NOT NULL - AND :endDate1 <= pe.dueDate - AND pe.dueDate <= :endDate2 - """) - List findAllWithStudentParticipationByRecentDueDate(@Param("endDate1") ZonedDateTime endDate1, @Param("endDate2") ZonedDateTime endDate2); - - @Query(""" - SELECT DISTINCT pe - FROM ProgrammingExercise pe - LEFT JOIN FETCH pe.studentParticipations - WHERE pe.exerciseGroup IS NOT NULL - AND :endDate1 <= pe.exerciseGroup.exam.endDate - AND pe.exerciseGroup.exam.endDate <= :endDate2 - """) - List findAllWithStudentParticipationByRecentExamEndDate(@Param("endDate1") ZonedDateTime endDate1, @Param("endDate2") ZonedDateTime endDate2); - @EntityGraph(type = LOAD, attributePaths = { "studentParticipations", "studentParticipations.team", "studentParticipations.team.students" }) Optional findWithEagerStudentParticipationsById(long exerciseId); @@ -347,9 +327,34 @@ Optional findByIdWithEagerTestCasesStaticCodeAnalysisCatego LEFT JOIN FETCH p.buildConfig WHERE p.id = :exerciseId """) - Optional findByIdWithEagerBuildConfigTestCasesStaticCodeAnalysisCategoriesHintsAndTemplateAndSolutionParticipationsAndAuxRepos( + Optional findByIdWithEagerBuildConfigTestCasesStaticCodeAnalysisCategoriesHintsAndTemplateAndSolutionParticipationsAndAuxReposAndSolutionEntriesAndBuildConfig( @Param("exerciseId") long exerciseId); + default ProgrammingExercise findByIdWithEagerTestCasesStaticCodeAnalysisCategoriesHintsAndTemplateAndSolutionParticipationsAndAuxReposAndBuildConfigElseThrow(long exerciseId) + throws EntityNotFoundException { + return getValueElseThrow(findByIdWithEagerTestCasesStaticCodeAnalysisCategoriesHintsAndTemplateAndSolutionParticipationsAndAuxReposAndBuildConfig(exerciseId), exerciseId); + } + + @Query(""" + SELECT p + FROM ProgrammingExercise p + LEFT JOIN FETCH p.testCases tc + LEFT JOIN FETCH p.staticCodeAnalysisCategories + LEFT JOIN FETCH p.exerciseHints + LEFT JOIN FETCH p.templateParticipation + LEFT JOIN FETCH p.solutionParticipation + LEFT JOIN FETCH p.auxiliaryRepositories + LEFT JOIN FETCH tc.solutionEntries + LEFT JOIN FETCH p.buildConfig + LEFT JOIN FETCH p.plagiarismDetectionConfig + WHERE p.id = :exerciseId + """) + Optional findByIdForImport(@Param("exerciseId") long exerciseId); + + default ProgrammingExercise findByIdForImportElseThrow(long exerciseId) throws EntityNotFoundException { + return getValueElseThrow(findByIdForImport(exerciseId), exerciseId); + } + /** * Returns all programming exercises that have a due date after {@code now} and have tests marked with * {@link Visibility#AFTER_DUE_DATE} but no buildAndTestStudentSubmissionsAfterDueDate. @@ -537,6 +542,24 @@ SELECT COUNT (DISTINCT p) """) Optional findByIdWithGradingCriteria(@Param("exerciseId") long exerciseId); + @Query(""" + SELECT e + FROM ProgrammingExercise e + LEFT JOIN FETCH e.competencies + WHERE e.title = :title + AND e.course.id = :courseId + """) + Optional findWithCompetenciesByTitleAndCourseId(@Param("title") String title, @Param("courseId") long courseId); + + @Query(""" + SELECT e + FROM ProgrammingExercise e + LEFT JOIN FETCH e.competencies + WHERE e.shortName = :shortName + AND e.course.id = :courseId + """) + Optional findByShortNameAndCourseIdWithCompetencies(@Param("shortName") String shortName, @Param("courseId") long courseId); + default ProgrammingExercise findByIdWithGradingCriteriaElseThrow(long exerciseId) { return getValueElseThrow(findByIdWithGradingCriteria(exerciseId), exerciseId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java index 8929c407336b..3739ed8dff71 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java @@ -9,6 +9,8 @@ import java.util.Optional; import org.springframework.context.annotation.Profile; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -162,6 +164,21 @@ List findWithSubmissionsByExerciseIdAnd Optional findWithSubmissionsByExerciseIdAndStudentLoginAndTestRun(@Param("exerciseId") long exerciseId, @Param("username") String username, @Param("testRun") boolean testRun); + @Query(""" + SELECT participation.repositoryUri + FROM ProgrammingExerciseStudentParticipation participation + JOIN TREAT (participation.exercise AS ProgrammingExercise) pe + LEFT JOIN pe.exerciseGroup eg + LEFT JOIN eg.exam exam + WHERE participation.repositoryUri IS NOT NULL + AND ( + (pe.dueDate IS NOT NULL AND pe.dueDate BETWEEN :earliestDate AND :latestDate) + OR (eg IS NOT NULL AND exam IS NOT NULL AND exam.endDate BETWEEN :earliestDate AND :latestDate) + ) + """) + Page findRepositoryUrisByRecentDueDateOrRecentExamEndDate(@Param("earliestDate") ZonedDateTime earliestDate, @Param("latestDate") ZonedDateTime latestDate, + Pageable pageable); + @Query(""" SELECT participation FROM ProgrammingExerciseStudentParticipation participation diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/AutomaticProgrammingExerciseCleanupService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/AutomaticProgrammingExerciseCleanupService.java index e1ba117d2148..f1989c28865c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/AutomaticProgrammingExerciseCleanupService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/AutomaticProgrammingExerciseCleanupService.java @@ -3,6 +3,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_SCHEDULING; import static java.time.ZonedDateTime.now; +import java.net.URISyntaxException; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.HashSet; @@ -16,6 +17,8 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -25,6 +28,7 @@ import de.tum.cit.aet.artemis.exercise.service.ParticipationService; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation; +import de.tum.cit.aet.artemis.programming.domain.VcsRepositoryUri; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseStudentParticipationRepository; @@ -44,6 +48,8 @@ public class AutomaticProgrammingExerciseCleanupService { private final GitService gitService; + private static final int STUDENT_PARTICIPATION_CLEANUP_BATCH_SIZE = 500; + @Value("${artemis.external-system-request.batch-size}") private int externalSystemRequestBatchSize; @@ -82,49 +88,68 @@ public void cleanup() { log.error("Exception occurred during cleanupBuildPlansOnContinuousIntegrationServer", ex); } try { - cleanupGitRepositoriesOnArtemisServer(); + cleanupGitWorkingCopiesOnArtemisServer(); } catch (Exception ex) { - log.error("Exception occurred during cleanupGitRepositoriesOnArtemisServer", ex); + log.error("Exception occurred during cleanupGitWorkingCopiesOnArtemisServer", ex); } } /** * cleans up old local git repositories on the Artemis server */ - public void cleanupGitRepositoriesOnArtemisServer() { + public void cleanupGitWorkingCopiesOnArtemisServer() { SecurityUtils.setAuthorizationObject(); log.info("Cleanup git repositories on Artemis server"); // we are specifically interested in exercises older than 8 weeks - var endDate2 = ZonedDateTime.now().minusWeeks(8).truncatedTo(ChronoUnit.DAYS); + var latestDate = ZonedDateTime.now().minusWeeks(8).truncatedTo(ChronoUnit.DAYS); // NOTE: for now we would like to cover more cases to also cleanup older repositories - var endDate1 = endDate2.minusYears(1).truncatedTo(ChronoUnit.DAYS); - - // Cleanup all student repos in the REPOS folder (based on the student participations) 8 weeks after the exercise due date - log.info("Search for exercises with due date from {} until {}", endDate1, endDate2); - var programmingExercises = programmingExerciseRepository.findAllWithStudentParticipationByRecentDueDate(endDate1, endDate2); - programmingExercises.addAll(programmingExerciseRepository.findAllWithStudentParticipationByRecentExamEndDate(endDate1, endDate2)); - log.info("Found {} programming exercises {} to clean {} local student repositories", programmingExercises.size(), - programmingExercises.stream().map(ProgrammingExercise::getProjectKey).collect(Collectors.joining(", ")), - programmingExercises.stream().mapToLong(programmingExercise -> programmingExercise.getStudentParticipations().size()).sum()); - for (var programmingExercise : programmingExercises) { - for (var studentParticipation : programmingExercise.getStudentParticipations()) { - var programmingExerciseParticipation = (ProgrammingExerciseStudentParticipation) studentParticipation; - gitService.deleteLocalRepository(programmingExerciseParticipation.getVcsRepositoryUri()); - } - } + var earliestDate = latestDate.minusYears(1).truncatedTo(ChronoUnit.DAYS); + + // Cleanup all student repos in the REPOS folder (based on the student participations) 8 weeks after the exercise due date or exam end date + cleanStudentParticipationsRepositories(earliestDate, latestDate); // Cleanup template, tests and solution repos in the REPOS folder 8 weeks after the course or exam is over - log.info("Search for exercises with course or exam date from {} until {}", endDate1, endDate2); - programmingExercises = programmingExerciseRepository.findAllByRecentCourseEndDate(endDate1, endDate2); - programmingExercises.addAll(programmingExerciseRepository.findAllByRecentExamEndDate(endDate1, endDate2)); + log.info("Search for exercises with course or exam date from {} until {}", earliestDate, latestDate); + var programmingExercises = programmingExerciseRepository.findAllByRecentCourseEndDate(earliestDate, latestDate); + programmingExercises.addAll(programmingExerciseRepository.findAllByRecentExamEndDate(earliestDate, latestDate)); log.info("Found {} programming exercise to clean local template, test and solution: {}", programmingExercises.size(), programmingExercises.stream().map(ProgrammingExercise::getProjectKey).collect(Collectors.joining(", "))); - for (var programmingExercise : programmingExercises) { - gitService.deleteLocalRepository(programmingExercise.getVcsTemplateRepositoryUri()); - gitService.deleteLocalRepository(programmingExercise.getVcsSolutionRepositoryUri()); - gitService.deleteLocalRepository(programmingExercise.getVcsTestRepositoryUri()); - gitService.deleteLocalProgrammingExerciseReposFolder(programmingExercise); + if (!programmingExercises.isEmpty()) { + for (var programmingExercise : programmingExercises) { + gitService.deleteLocalRepository(programmingExercise.getVcsTemplateRepositoryUri()); + gitService.deleteLocalRepository(programmingExercise.getVcsSolutionRepositoryUri()); + gitService.deleteLocalRepository(programmingExercise.getVcsTestRepositoryUri()); + gitService.deleteLocalProgrammingExerciseReposFolder(programmingExercise); + } + log.info("Finished cleaning local template, test and solution repositories"); + } + } + + private void cleanStudentParticipationsRepositories(ZonedDateTime earliestDate, ZonedDateTime latestDate) { + log.info("Search for exercises with due date from {} until {}", earliestDate, latestDate); + // Get all relevant participation ids + Pageable pageable = Pageable.ofSize(STUDENT_PARTICIPATION_CLEANUP_BATCH_SIZE); + Page uriBatch = programmingExerciseStudentParticipationRepository.findRepositoryUrisByRecentDueDateOrRecentExamEndDate(earliestDate, latestDate, pageable); + log.info("Found {} student participations to clean local student repositories in {} batches.", uriBatch.getTotalElements(), uriBatch.getTotalPages()); + if (uriBatch.getTotalElements() > 0) { + uriBatch.forEach(this::deleteLocalRepositoryByUriString); + while (!uriBatch.isLast()) { + uriBatch = programmingExerciseStudentParticipationRepository.findRepositoryUrisByRecentDueDateOrRecentExamEndDate(earliestDate, latestDate, + uriBatch.nextPageable()); + uriBatch.forEach(this::deleteLocalRepositoryByUriString); + } + log.info("Finished cleaning local student repositories"); + } + } + + private void deleteLocalRepositoryByUriString(String uri) { + try { + VcsRepositoryUri vcsRepositoryUrl = new VcsRepositoryUri(uri); + gitService.deleteLocalRepository(vcsRepositoryUrl); + } + catch (URISyntaxException e) { + log.error("Cannot create URI for repositoryUri: {}", uri, e); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java index 39bfac28ca0b..4507f1a4e76d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java @@ -113,7 +113,9 @@ public void processResult() { if (resultQueueItem == null) { return; } - log.info("Processing build job result"); + log.info("Processing build job result with id {}", resultQueueItem.buildJobQueueItem().id()); + log.debug("Build jobs waiting in queue: {}", resultQueue.size()); + log.debug("Queued build jobs: {}", resultQueue.stream().map(i -> i.buildJobQueueItem().id()).toList()); BuildJobQueueItem buildJob = resultQueueItem.buildJobQueueItem(); BuildResult buildResult = resultQueueItem.buildResult(); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java index 8a9aa499ea2e..10569adbbf30 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java @@ -149,6 +149,8 @@ public void triggerBuild(ProgrammingExerciseParticipation participation, String private void triggerBuild(ProgrammingExerciseParticipation participation, String commitHashToBuild, RepositoryType triggeredByPushTo, boolean triggerAll) throws LocalCIException { + log.info("Triggering build for participation {} and commit hash {}", participation.getId(), commitHashToBuild); + // Commit hash related to the repository that will be tested String assignmentCommitHash; diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java index f1da13be2453..2222e4a5f3d9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseExportImportResource.java @@ -209,7 +209,8 @@ public ResponseEntity importProgrammingExercise(@PathVariab programmingExerciseRepository.validateCourseSettings(newExercise, course); final var originalProgrammingExercise = programmingExerciseRepository - .findByIdWithEagerBuildConfigTestCasesStaticCodeAnalysisCategoriesHintsAndTemplateAndSolutionParticipationsAndAuxRepos(sourceExerciseId) + .findByIdWithEagerBuildConfigTestCasesStaticCodeAnalysisCategoriesHintsAndTemplateAndSolutionParticipationsAndAuxReposAndSolutionEntriesAndBuildConfig( + sourceExerciseId) .orElseThrow(() -> new EntityNotFoundException("ProgrammingExercise", sourceExerciseId)); var consistencyErrors = consistencyCheckService.checkConsistencyOfProgrammingExercise(originalProgrammingExercise); diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/domain/DragAndDropSubmittedAnswer.java b/src/main/java/de/tum/cit/aet/artemis/quiz/domain/DragAndDropSubmittedAnswer.java index cfc17f0b42ee..7ab065bb4920 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/domain/DragAndDropSubmittedAnswer.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/domain/DragAndDropSubmittedAnswer.java @@ -76,7 +76,6 @@ public DragItem getSelectedDragItemForDropLocation(DropLocation dropLocation) { * @param question the changed question with the changed DragItems and DropLocations */ private void checkAndDeleteMappings(DragAndDropQuestion question) { - if (question != null) { // Check if a dragItem or dropLocation was deleted and delete reference to it in mappings Set selectedMappingsToDelete = new HashSet<>(); @@ -98,7 +97,6 @@ private void checkAndDeleteMappings(DragAndDropQuestion question) { */ @Override public void checkAndDeleteReferences(QuizExercise quizExercise) { - // Delete all references to question, dropLocations and dragItem if the question was deleted if (!quizExercise.getQuizQuestions().contains(getQuizQuestion())) { setQuizQuestion(null); diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/domain/DragItem.java b/src/main/java/de/tum/cit/aet/artemis/quiz/domain/DragItem.java index bcf311e9e31c..e657930b29f9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/domain/DragItem.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/domain/DragItem.java @@ -109,6 +109,10 @@ public void setInvalid(Boolean invalid) { this.invalid = invalid; } + public void setMappings(Set mappings) { + this.mappings = mappings; + } + /** * 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. diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/domain/DropLocation.java b/src/main/java/de/tum/cit/aet/artemis/quiz/domain/DropLocation.java index 8237750bf7b0..468210dc7cab 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/domain/DropLocation.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/domain/DropLocation.java @@ -128,6 +128,10 @@ public void setInvalid(Boolean invalid) { this.invalid = invalid; } + public void setMappings(Set mappings) { + this.mappings = mappings; + } + /** * check if the DropLocation is solved correctly * diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/domain/ShortAnswerSolution.java b/src/main/java/de/tum/cit/aet/artemis/quiz/domain/ShortAnswerSolution.java index f55298937de9..448507563f89 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/domain/ShortAnswerSolution.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/domain/ShortAnswerSolution.java @@ -77,6 +77,10 @@ public void setQuestion(ShortAnswerQuestion shortAnswerQuestion) { this.question = shortAnswerQuestion; } + public void setMappings(Set mappings) { + this.mappings = mappings; + } + @Override public String toString() { return "ShortAnswerSolution{" + "id=" + getId() + ", text='" + getText() + "'" + ", invalid='" + isInvalid() + "'" + "}"; diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/domain/ShortAnswerSpot.java b/src/main/java/de/tum/cit/aet/artemis/quiz/domain/ShortAnswerSpot.java index 2d428efa5acb..727861554b7f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/domain/ShortAnswerSpot.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/domain/ShortAnswerSpot.java @@ -94,6 +94,10 @@ public void setQuestion(ShortAnswerQuestion shortAnswerQuestion) { this.question = shortAnswerQuestion; } + public void setMappings(Set shortAnswerMappings) { + this.mappings = shortAnswerMappings; + } + @Override public String toString() { return "ShortAnswerSpot{" + "id=" + getId() + ", width=" + getWidth() + ", spotNr=" + getSpotNr() + ", invalid='" + isInvalid() + "'" + "}"; diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizExerciseRepository.java b/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizExerciseRepository.java index 5ab0a9bbb1a1..6f31cc005d1d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizExerciseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizExerciseRepository.java @@ -6,6 +6,7 @@ import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; +import java.util.Set; import jakarta.validation.constraints.NotNull; @@ -16,6 +17,7 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import de.tum.cit.aet.artemis.core.exception.NoUniqueQueryException; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; import de.tum.cit.aet.artemis.quiz.domain.QuizExercise; @@ -60,8 +62,9 @@ public interface QuizExerciseRepository extends ArtemisJpaRepository findWithEagerQuestionsAndStatisticsById(Long quizExerciseId); - @EntityGraph(type = LOAD, attributePaths = { "quizQuestions", "quizPointStatistic", "quizQuestions.quizQuestionStatistic", "categories", "competencies", "quizBatches" }) - Optional findWithEagerQuestionsAndStatisticsAndCompetenciesById(Long quizExerciseId); + @EntityGraph(type = LOAD, attributePaths = { "quizQuestions", "quizPointStatistic", "quizQuestions.quizQuestionStatistic", "categories", "competencies", "quizBatches", + "gradingCriteria" }) + Optional findWithEagerQuestionsAndStatisticsAndCompetenciesAndBatchesAndGradingCriteriaById(Long quizExerciseId); @EntityGraph(type = LOAD, attributePaths = { "quizQuestions" }) Optional findWithEagerQuestionsById(Long quizExerciseId); @@ -72,6 +75,31 @@ public interface QuizExerciseRepository extends ArtemisJpaRepository findWithEagerBatchesById(Long quizExerciseId); + @Query(""" + SELECT q + FROM QuizExercise q + LEFT JOIN FETCH q.competencies + WHERE q.title = :title + AND q.course.id = :courseId + """) + Set findAllWithCompetenciesByTitleAndCourseId(@Param("title") String title, @Param("courseId") long courseId); + + /** + * Finds a quiz exercise by its title and course id and throws a NoUniqueQueryException if multiple exercises are found. + * + * @param title the title of the exercise + * @param courseId the id of the course + * @return the exercise with the given title and course id + * @throws NoUniqueQueryException if multiple exercises are found with the same title + */ + default Optional findUniqueWithCompetenciesByTitleAndCourseId(String title, long courseId) throws NoUniqueQueryException { + Set allExercises = findAllWithCompetenciesByTitleAndCourseId(title, courseId); + if (allExercises.size() > 1) { + throw new NoUniqueQueryException("Found multiple exercises with title " + title + " in course with id " + courseId); + } + return allExercises.stream().findFirst(); + } + @NotNull default QuizExercise findWithEagerBatchesByIdOrElseThrow(Long quizExerciseId) { return getValueElseThrow(findWithEagerBatchesById(quizExerciseId), quizExerciseId); @@ -116,7 +144,7 @@ default QuizExercise findByIdWithQuestionsAndStatisticsElseThrow(Long quizExerci } @NotNull - default QuizExercise findByIdWithQuestionsAndStatisticsAndCompetenciesElseThrow(Long quizExerciseId) { - return getValueElseThrow(findWithEagerQuestionsAndStatisticsAndCompetenciesById(quizExerciseId), quizExerciseId); + default QuizExercise findByIdWithQuestionsAndStatisticsAndCompetenciesAndBatchesAndGradingCriteriaElseThrow(Long quizExerciseId) { + return getValueElseThrow(findWithEagerQuestionsAndStatisticsAndCompetenciesAndBatchesAndGradingCriteriaById(quizExerciseId), quizExerciseId); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseImportService.java b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseImportService.java index d6f432199a27..ffe7989c1e91 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseImportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseImportService.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.net.URI; import java.nio.file.Path; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -128,6 +129,7 @@ private QuizExercise copyQuizExerciseBasis(QuizExercise importedExercise) { private void copyQuizQuestions(QuizExercise sourceExercise, QuizExercise newExercise) { log.debug("Copying the QuizQuestions to new QuizExercise: {}", newExercise); + List newQuestions = new ArrayList<>(); for (QuizQuestion quizQuestion : sourceExercise.getQuizQuestions()) { quizQuestion.setId(null); quizQuestion.setQuizQuestionStatistic(null); @@ -141,15 +143,21 @@ else if (quizQuestion instanceof ShortAnswerQuestion saQuestion) { setUpShortAnswerQuestionForImport(saQuestion); } quizQuestion.setExercise(newExercise); + + newQuestions.add(quizQuestion); } - newExercise.setQuizQuestions(sourceExercise.getQuizQuestions()); + newExercise.setQuizQuestions(newQuestions); } private void setUpMultipleChoiceQuestionForImport(MultipleChoiceQuestion mcQuestion) { + List newAnswerOptions = new ArrayList<>(); for (AnswerOption answerOption : mcQuestion.getAnswerOptions()) { answerOption.setId(null); answerOption.setQuestion(mcQuestion); + + newAnswerOptions.add(answerOption); } + mcQuestion.setAnswerOptions(newAnswerOptions); } private void setUpDragAndDropQuestionForImport(DragAndDropQuestion dndQuestion) { @@ -171,19 +179,29 @@ private void setUpDragAndDropQuestionForImport(DragAndDropQuestion dndQuestion) log.warn("BackgroundFilePath of DragAndDropQuestion {} is null", dndQuestion.getId()); } + List newDropLocations = new ArrayList<>(); for (DropLocation dropLocation : dndQuestion.getDropLocations()) { dropLocation.setId(null); dropLocation.setQuestion(dndQuestion); + dropLocation.setMappings(new HashSet<>()); + + newDropLocations.add(dropLocation); } + dndQuestion.setDropLocations(newDropLocations); setUpDragItemsForImport(dndQuestion); setUpDragAndDropMappingsForImport(dndQuestion); } private void setUpDragItemsForImport(DragAndDropQuestion dndQuestion) { + List newDragItems = new ArrayList<>(); for (DragItem dragItem : dndQuestion.getDragItems()) { dragItem.setId(null); dragItem.setQuestion(dndQuestion); + dragItem.setMappings(new HashSet<>()); + + newDragItems.add(dragItem); + if (dragItem.getPictureFilePath() == null) { continue; } @@ -201,9 +219,11 @@ private void setUpDragItemsForImport(DragAndDropQuestion dndQuestion) { dragItem.setPictureFilePath(FilePathService.publicPathForActualPathOrThrow(newDragItemPath, null).toString()); } } + dndQuestion.setDragItems(newDragItems); } private void setUpDragAndDropMappingsForImport(DragAndDropQuestion dndQuestion) { + List newDragAndDropMappings = new ArrayList<>(); for (DragAndDropMapping dragAndDropMapping : dndQuestion.getCorrectMappings()) { dragAndDropMapping.setId(null); dragAndDropMapping.setQuestion(dndQuestion); @@ -213,18 +233,34 @@ private void setUpDragAndDropMappingsForImport(DragAndDropQuestion dndQuestion) if (dragAndDropMapping.getDropLocationIndex() != null) { dragAndDropMapping.setDropLocation(dndQuestion.getDropLocations().get(dragAndDropMapping.getDropLocationIndex())); } + + newDragAndDropMappings.add(dragAndDropMapping); } + dndQuestion.setCorrectMappings(newDragAndDropMappings); } private void setUpShortAnswerQuestionForImport(ShortAnswerQuestion saQuestion) { + List newShortAnswerSpots = new ArrayList<>(); for (ShortAnswerSpot shortAnswerSpot : saQuestion.getSpots()) { shortAnswerSpot.setId(null); shortAnswerSpot.setQuestion(saQuestion); + shortAnswerSpot.setMappings(new HashSet<>()); + + newShortAnswerSpots.add(shortAnswerSpot); } + saQuestion.setSpots(newShortAnswerSpots); + + List newShortAnswerSolutions = new ArrayList<>(); for (ShortAnswerSolution shortAnswerSolution : saQuestion.getSolutions()) { shortAnswerSolution.setId(null); shortAnswerSolution.setQuestion(saQuestion); + shortAnswerSolution.setMappings(new HashSet<>()); + + newShortAnswerSolutions.add(shortAnswerSolution); } + saQuestion.setSolutions(newShortAnswerSolutions); + + List newShortAnswerMappings = new ArrayList<>(); for (ShortAnswerMapping shortAnswerMapping : saQuestion.getCorrectMappings()) { shortAnswerMapping.setId(null); shortAnswerMapping.setQuestion(saQuestion); @@ -234,7 +270,9 @@ private void setUpShortAnswerQuestionForImport(ShortAnswerQuestion saQuestion) { if (shortAnswerMapping.getShortAnswerSpotIndex() != null) { shortAnswerMapping.setSpot(saQuestion.getSpots().get(shortAnswerMapping.getShortAnswerSpotIndex())); } + newShortAnswerMappings.add(shortAnswerMapping); } + saQuestion.setCorrectMappings(newShortAnswerMappings); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseWithSubmissionsExportService.java b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseWithSubmissionsExportService.java index 207a84d3220d..0b44382a8de5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseWithSubmissionsExportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizExerciseWithSubmissionsExportService.java @@ -57,7 +57,7 @@ public QuizExerciseWithSubmissionsExportService(QuizExerciseRepository quizExerc * @return the path to the directory where the quiz exercise was exported to */ public Path exportExerciseWithSubmissions(QuizExercise quizExercise, Path exerciseExportDir, List exportErrors, List reportEntries) { - quizExercise = quizExerciseRepository.findByIdWithQuestionsAndStatisticsAndCompetenciesElseThrow(quizExercise.getId()); + quizExercise = quizExerciseRepository.findByIdWithQuestionsAndStatisticsAndCompetenciesAndBatchesAndGradingCriteriaElseThrow(quizExercise.getId()); // do not store unnecessary information in the JSON file quizExercise.setCourse(null); quizExercise.setExerciseGroup(null); diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java index 2f055ac9bf09..118c5554a1f9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizExerciseResource.java @@ -371,7 +371,7 @@ public ResponseEntity getQuizExercise(@PathVariable Long quizExerc // TODO: Split this route in two: One for normal and one for exam exercises log.info("REST request to get quiz exercise : {}", quizExerciseId); var user = userRepository.getUserWithGroupsAndAuthorities(); - var quizExercise = quizExerciseRepository.findByIdWithQuestionsAndStatisticsAndCompetenciesElseThrow(quizExerciseId); + var quizExercise = quizExerciseRepository.findByIdWithQuestionsAndStatisticsAndCompetenciesAndBatchesAndGradingCriteriaElseThrow(quizExerciseId); if (quizExercise.isExamExercise()) { authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.EDITOR, quizExercise, user); studentParticipationRepository.checkTestRunsExist(quizExercise); diff --git a/src/main/java/de/tum/cit/aet/artemis/text/repository/TextExerciseRepository.java b/src/main/java/de/tum/cit/aet/artemis/text/repository/TextExerciseRepository.java index 6ed0ef96f4a7..3171fa825b63 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/repository/TextExerciseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/repository/TextExerciseRepository.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; import jakarta.validation.constraints.NotNull; @@ -15,6 +16,7 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import de.tum.cit.aet.artemis.core.exception.NoUniqueQueryException; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; import de.tum.cit.aet.artemis.text.domain.TextExercise; @@ -42,6 +44,20 @@ public interface TextExerciseRepository extends ArtemisJpaRepository findWithEagerTeamAssignmentConfigAndCategoriesAndCompetenciesAndPlagiarismDetectionConfigById(long exerciseId); + @Query(""" + SELECT t + FROM TextExercise t + LEFT JOIN FETCH t.exampleSubmissions e + LEFT JOIN FETCH e.submission s + LEFT JOIN FETCH s.results r + LEFT JOIN FETCH r.feedbacks + LEFT JOIN FETCH s.blocks + LEFT JOIN FETCH r.assessor + LEFT JOIN FETCH t.teamAssignmentConfig + WHERE t.id = :exerciseId + """) + Optional findWithExampleSubmissionsAndResultsById(@Param("exerciseId") long exerciseId); + @Query(""" SELECT textExercise FROM TextExercise textExercise @@ -52,9 +68,10 @@ public interface TextExerciseRepository extends ArtemisJpaRepository findWithExampleSubmissionsAndResultsById(@Param("exerciseId") long exerciseId); + Optional findWithExampleSubmissionsAndResultsAndGradingCriteriaById(@Param("exerciseId") long exerciseId); @EntityGraph(type = LOAD, attributePaths = { "studentParticipations", "studentParticipations.submissions", "studentParticipations.submissions.results" }) Optional findWithStudentParticipationsAndSubmissionsById(long exerciseId); @@ -62,6 +79,31 @@ public interface TextExerciseRepository extends ArtemisJpaRepository findWithGradingCriteriaById(long exerciseId); + @Query(""" + SELECT t + FROM TextExercise t + LEFT JOIN FETCH t.competencies + WHERE t.title = :title + AND t.course.id = :courseId + """) + Set findAllWithCompetenciesByTitleAndCourseId(@Param("title") String title, @Param("courseId") long courseId); + + /** + * Finds a text exercise by its title and course id and throws a NoUniqueQueryException if multiple exercises are found. + * + * @param title the title of the exercise + * @param courseId the id of the course + * @return the exercise with the given title and course id + * @throws NoUniqueQueryException if multiple exercises are found with the same title + */ + default Optional findUniqueWithCompetenciesByTitleAndCourseId(String title, long courseId) throws NoUniqueQueryException { + Set allExercises = findAllWithCompetenciesByTitleAndCourseId(title, courseId); + if (allExercises.size() > 1) { + throw new NoUniqueQueryException("Found multiple exercises with title " + title + " in course with id " + courseId); + } + return allExercises.stream().findFirst(); + } + @NotNull default TextExercise findWithGradingCriteriaByIdElseThrow(long exerciseId) { return getValueElseThrow(findWithGradingCriteriaById(exerciseId), exerciseId); @@ -77,6 +119,11 @@ default TextExercise findByIdWithExampleSubmissionsAndResultsElseThrow(long exer return getValueElseThrow(findWithExampleSubmissionsAndResultsById(exerciseId), exerciseId); } + @NotNull + default TextExercise findByIdWithExampleSubmissionsAndResultsAndGradingCriteriaElseThrow(long exerciseId) { + return getValueElseThrow(findWithExampleSubmissionsAndResultsAndGradingCriteriaById(exerciseId), exerciseId); + } + @NotNull default TextExercise findByIdWithStudentParticipationsAndSubmissionsElseThrow(long exerciseId) { return getValueElseThrow(findWithStudentParticipationsAndSubmissionsById(exerciseId), exerciseId); diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 1b148cca8b2b..0879e526d4f4 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -79,8 +79,6 @@ email.notification.aux.information.release.date=Release Date : {0} email.notification.aux.information.due.date=Due Date : {0} email.notification.aux.information.submission.date=Submission Date : {0} -email.notification.aux.notification.post.content=Content: - # Exercise Types email.notification.aux.exercise.type.quiz=The quiz exercise email.notification.aux.exercise.type.programming=The programming exercise diff --git a/src/main/resources/i18n/messages_de.properties b/src/main/resources/i18n/messages_de.properties index f1fa989cf5f7..289a66b117a2 100644 --- a/src/main/resources/i18n/messages_de.properties +++ b/src/main/resources/i18n/messages_de.properties @@ -15,7 +15,7 @@ email.activation.text2=Grüße email.signature=Das Artemis Team. # Creation email -email.creation.text1=Dein Artemis Zugang wurde angelegt, bitte klicke auf den Link um dich anzumelden: +email.creation.text1=Dein Artemis Zugang wurde angelegt, bitte klicke auf den Link, um dich anzumelden: # Reset email email.reset.title=Artemis Passwort zurücksetzen @@ -26,7 +26,7 @@ email.reset.text2=Grüße, # SAML2 Account created email.saml.title=Artemis Account angelegt email.saml.greeting=Liebe(r) {0} -email.saml.text1=Dein Artemis Account wurde angelegt. Setze über den Link ein lokales App-Passwort, um auf Artemis und die verknüpften Dienste (Git, Build-Server,...) zuzugreifen. +email.saml.text1=Dein Artemis Account wurde angelegt. Setze über den Link ein lokales App-Passwort, um auf Artemis und die verknüpften Dienste (Git, Build-Server, ...) zuzugreifen. email.saml.text2=Nach Ablauf des Links kann das Passwort weiterhin über die "Passwort vergessen"-Funktion gesetzt werden. email.saml.text3=Grüße, email.saml.username=Nutzername: {0} @@ -48,7 +48,7 @@ email.notification.group.editors="Editor:innen" # Notification Titles (based on originating type) email.notification.title.attachment=Der Anhang "{0}" für die Vorlesung "{1}" in dem Kurs "{2}" wurde aktualisiert. -email.notification.title.file.submission.successful=Die Einreichung der Dateiupload-aufgabe "{0}" in dem Kurs "{1}" war erfolgreich. +email.notification.title.file.submission.successful=Die Einreichung der Dateiupload-Aufgabe "{0}" in dem Kurs "{1}" war erfolgreich. email.notification.title.exercise.submission.assessed=Die eingereichte Lösung für die Aufgabe "{0}" in dem Kurs "{1}" wurde korrigiert. email.notification.title.duplicate.test.cases="{0}" in dem Kurs"{1}" hat mehrere Testfälle mit gleichen Namen! Dieser kritische Fehler sollte so früh wie möglich korrigiert werden, sonst treten Probleme bei der Erstellung von Ergebnissen für Studierende auf! @@ -61,7 +61,7 @@ email.notification.title.exercise.practice="{0}" im Kurs "{1}" wurde zum Ãœben f # Exercise Info email.notification.title.exercise.information=Informationen zu der Aufgabe: -email.notification.title.exercise.information.difficulty=Schwierigkeitsstufe : {0} +email.notification.title.exercise.information.difficulty=Schwierigkeitsstufe: {0} email.notification.title.exercise.information.max=Anzahl an Punkten: {0} email.notification.title.exercise.information.bonus=Anzahl an Bonus Punkten: {0} email.notification.title.exercise.information.possible=Anzahl maximal erreichbarer Punkte: {0} @@ -69,8 +69,8 @@ email.notification.aux.information.exercise.score=Dein erreichtes Ergebnis: {0}% # Auxiliary -email.notification.aux.notification.text.header.change.message=Änderungsnachricht : -email.notification.aux.footer=Diese und ähnliche Emails können (de)aktiviert werden: +email.notification.aux.notification.text.header.change.message=Änderungsnachricht: +email.notification.aux.footer=Diese und ähnliche E-Mails können (de)aktiviert werden: email.notification.aux.footer.link=Benachrichtigungseinstellungen in Artemis email.notification.aux.open.button=In Artemis öffnen email.notification.aux.emergency.link.text=Alternativ kann auch folgender Link verwendet werden: @@ -79,8 +79,6 @@ email.notification.aux.information.release.date=Veröffentlichungsdatum: {0} email.notification.aux.information.due.date=Abgabezeitpunkt: {0} email.notification.aux.information.submission.date=Einreichungsdatum: {0} -email.notification.aux.notification.post.content=Inhalt: - # Exercise Types email.notification.aux.exercise.type.quiz=Die Quizaufgabe email.notification.aux.exercise.type.programming=Die Programmieraufgabe @@ -95,7 +93,7 @@ email.notification.aux.difficulty.hard=Schwer # Plagiarism email.plagiarism.title=Neuer Plagiatsfall: Ãœbung "{0}" im Kurs "{1}" -email.plagiarism.cpc.title=Neue signifikante Übereinstimmung: Aufgabe "{0}" im Kurs "{1}" +email.plagiarism.cpc.title=Neue signifikante ?bereinstimmung: Aufgabe "{0}" im Kurs "{1}" email.notification.title.post.plagiarismVerdict=Entscheidung zum Plagiatsfall in der Aufgabe {0} gefallen email.notification.aux.plagiarismVerdict.plagiarism=Der Fall wird als Plagiat angesehen! email.notification.aux.plagiarismVerdict.point.deduction=Wegen des Plagiatsfalls ziehen wir dir Punkte in der Aufgabe ab! @@ -124,8 +122,8 @@ email.dataExportFailedAdmin.actionItemList = Bitte führe die folgenden beiden A email.dataExportFailedAdmin.actionItem1 = \u2022 Stelle sicher, dass die Konfiguration deiner Artemis Instanz korrekt ist. email.dataExportFailedAdmin.actionItem2 = \u2022 Falls du weitere Hilfe benötigst, kontaktiere das Artemis Entwicklungsteam, indem du mit dem folgenden Link ein Issue auf GitHub erstellst: email.dataExportFailedAdmin.githubLink = Link um ein Issue im Artemis GitHub Projekt anzulegen -email.successfulDataExportCreationsAdmin.title = Angeforderte Datenexporte wurden für deine Instanz erfolgreich erstellt -email.successfulDataExportCreationsAdmin.text = Datenexporte für die folgenden Nutzer wurden erfolgreich erstellt als der Job um die Datenexporte zu erstellen zuletzt ausgeführt wurde: +email.successfulDataExportCreationsAdmin.title = Angeforderte Datenexporte wurden f?r deine Instanz erfolgreich erstellt +email.successfulDataExportCreationsAdmin.text = Datenexporte f?r die folgenden Nutzer wurden erfolgreich erstellt als der Job um die Datenexporte zu erstellen zuletzt ausgef?hrt wurde: email.successfulDataExportCreationsAdmin.userLogin = \u2022 {0} # Email Subjects # The reason for the format artemisApp.{notificationCategory}.title.{notificicationType} is that these placeholders are also used in the client and this is the format used there @@ -138,7 +136,7 @@ artemisApp.groupNotification.title.newAnnouncementPost = Neue Ankündigung artemisApp.singleUserNotification.title.exerciseSubmissionAssessed = Ãœbungsabgabe bewertet artemisApp.singleUserNotification.title.fileSubmissionSuccessful = Dateiabgabe erfolgreich artemisApp.singleUserNotification.title.newPlagiarismCaseStudent = Neuer Plagiatsfall -artemisApp.singleUserNotification.title.newPlagiarismCaseStudentSignificantSimilarity = Neue signifikante Übereinstimmung +artemisApp.singleUserNotification.title.newPlagiarismCaseStudentSignificantSimilarity = Neue signifikante ?bereinstimmung artemisApp.singleUserNotification.title.plagiarismCaseVerdictStudent = Urteil zu deinem Plagiatsfall artemisApp.singleUserNotification.title.tutorialGroupRegistrationStudent = Du wurdest für eine Ãœbungsgruppe registriert artemisApp.singleUserNotification.title.tutorialGroupDeregistrationStudent = Du wurdest von einer Ãœbungsgruppe abgemeldet diff --git a/src/main/resources/i18n/messages_en.properties b/src/main/resources/i18n/messages_en.properties index 8895514a5364..ef2bc15f8c9b 100644 --- a/src/main/resources/i18n/messages_en.properties +++ b/src/main/resources/i18n/messages_en.properties @@ -29,7 +29,7 @@ email.saml.greeting=Dear {0} email.saml.text1=Your Artemis account has been created. A local Artemis password is only needed to access Git and build services. To create your local Artemis password click the link below: email.saml.text2=After expiration of this link you can use the "password-reset" button. email.saml.text3=Regards, -email.saml.username=User name: {0} +email.saml.username=Username: {0} email.saml.email=E-Mail: {0} # Weekly summary email @@ -79,8 +79,6 @@ email.notification.aux.information.release.date=Release Date : {0} email.notification.aux.information.due.date=Due Date : {0} email.notification.aux.information.submission.date=Submission Date : {0} -email.notification.aux.notification.post.content=Content: - # Exercise Types email.notification.aux.exercise.type.quiz=The quiz exercise email.notification.aux.exercise.type.programming=The programming exercise @@ -121,11 +119,11 @@ email.dataExportFailedAdmin.text = The data export for the user with the login email.dataExportFailedAdmin.textFailed = failed. email.dataExportFailedAdmin.reason = The exception message was the following: {0} email.dataExportFailedAdmin.actionItemList = Please complete the following action items: -email.dataExportFailedAdmin.actionItem1 = \u2022 Make sure the configuration for your Artemis instance is correct. +email.dataExportFailedAdmin.actionItem1 = \u2022 Make sure the configuration for your Artemis instance is correct. email.dataExportFailedAdmin.actionItem2 = \u2022 If you need further help, please contact the Artemis developers by opening an issue on GitHub using the link below: email.dataExportFailedAdmin.githubLink = Link to open an issue on the Artemis GitHub project email.successfulDataExportCreationsAdmin.title = Successfully created requested data exports for your instance -email.successfulDataExportCreationsAdmin.text = Data exports for the following users were successfully created when the data export creation job was ran: +email.successfulDataExportCreationsAdmin.text = Data exports for the following users were successfully created when the data export creation job was running: email.successfulDataExportCreationsAdmin.userLogin = \u2022 {0} # Email Subjects diff --git a/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts b/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts index 76db9f9fba09..ca7ce5bdd1d7 100644 --- a/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts +++ b/src/main/webapp/app/course/competencies/competency-management/competency-management.component.ts @@ -1,4 +1,4 @@ -import { Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { AlertService } from 'app/core/util/alert.service'; import { @@ -11,14 +11,11 @@ import { dtoToCompetencyRelation, getIcon, } from 'app/entities/competency.model'; -import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; -import { filter, map } from 'rxjs/operators'; import { onError } from 'app/shared/util/global.utils'; -import { Subject, Subscription, forkJoin } from 'rxjs'; +import { Subject, Subscription } from 'rxjs'; import { faFileImport, faPencilAlt, faPlus, faRobot, faTrash } from '@fortawesome/free-solid-svg-icons'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; -import { ImportAllCompetenciesComponent, ImportAllFromCourseResult } from 'app/course/competencies/competency-management/import-all-competencies.component'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; import { PROFILE_IRIS } from 'app/app.constants'; @@ -26,7 +23,11 @@ import { ConfirmAutofocusModalComponent } from 'app/shared/components/confirm-au import { TranslateService } from '@ngx-translate/core'; import { FeatureToggle, FeatureToggleService } from 'app/shared/feature-toggle/feature-toggle.service'; import { Prerequisite } from 'app/entities/prerequisite.model'; -import { CourseCompetencyService } from 'app/course/competencies/course-competency.service'; +import { + ImportAllCourseCompetenciesModalComponent, + ImportAllCourseCompetenciesResult, +} from 'app/course/competencies/components/import-all-course-competencies-modal/import-all-course-competencies-modal.component'; +import { CourseCompetencyApiService } from 'app/course/competencies/services/course-competency-api.service'; @Component({ selector: 'jhi-competency-management', @@ -60,7 +61,7 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { // Injected services private readonly activatedRoute: ActivatedRoute = inject(ActivatedRoute); - private readonly courseCompetencyService: CourseCompetencyService = inject(CourseCompetencyService); + private readonly courseCompetencyApiService: CourseCompetencyApiService = inject(CourseCompetencyApiService); private readonly alertService: AlertService = inject(AlertService); private readonly modalService: NgbModal = inject(NgbModal); private readonly profileService: ProfileService = inject(ProfileService); @@ -69,12 +70,10 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { private readonly featureToggleService: FeatureToggleService = inject(FeatureToggleService); ngOnInit(): void { - this.activatedRoute.parent!.params.subscribe((params) => { - this.courseId = params['courseId']; - if (this.courseId) { - this.loadData(); - this.loadIrisEnabled(); - } + this.activatedRoute.parent!.params.subscribe(async (params) => { + this.courseId = Number(params['courseId']); + await this.loadData(); + this.loadIrisEnabled(); }); this.standardizedCompetencySubscription = this.featureToggleService.getFeatureToggleActive(FeatureToggle.StandardizedCompetencies).subscribe((isActive) => { this.standardizedCompetenciesEnabled = isActive; @@ -107,54 +106,48 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { /** * Loads all data for the competency management: Prerequisites, competencies (with average course progress) and competency relations */ - loadData() { - this.isLoading = true; - const relationsObservable = this.courseCompetencyService.getCompetencyRelations(this.courseId); - const courseCompetenciesObservable = this.courseCompetencyService.getAllForCourse(this.courseId); - - forkJoin([relationsObservable, courseCompetenciesObservable]).subscribe({ - next: ([competencyRelations, courseCompetencies]) => { - const courseCompetenciesResponse = courseCompetencies.body ?? []; - this.competencies = courseCompetenciesResponse.filter((competency) => competency.type === CourseCompetencyType.COMPETENCY); - this.prerequisites = courseCompetenciesResponse.filter((competency) => competency.type === CourseCompetencyType.PREREQUISITE); - this.courseCompetencies = courseCompetenciesResponse; - this.relations = (competencyRelations.body ?? []).map((relationDTO) => dtoToCompetencyRelation(relationDTO)); - - this.isLoading = false; - }, - error: (errorResponse: HttpErrorResponse) => onError(this.alertService, errorResponse), - }); + async loadData() { + try { + this.isLoading = true; + this.relations = (await this.courseCompetencyApiService.getCourseCompetencyRelations(this.courseId)).map(dtoToCompetencyRelation); + this.courseCompetencies = await this.courseCompetencyApiService.getCourseCompetenciesByCourseId(this.courseId); + this.competencies = this.courseCompetencies.filter((competency) => competency.type === CourseCompetencyType.COMPETENCY); + this.prerequisites = this.courseCompetencies.filter((competency) => competency.type === CourseCompetencyType.PREREQUISITE); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading = false; + } } /** * Opens a modal for selecting a course to import all competencies from. */ - openImportAllModal() { - const modalRef = this.modalService.open(ImportAllCompetenciesComponent, { size: 'lg', backdrop: 'static' }); - //unary operator is necessary as otherwise courseId is seen as a string and will not match. - modalRef.componentInstance.disabledIds = [+this.courseId]; - modalRef.componentInstance.competencyType = 'courseCompetency'; - modalRef.result.then((result: ImportAllFromCourseResult) => { - const courseTitle = result.courseForImportDTO.title ?? ''; - - this.courseCompetencyService - .importAll(this.courseId, result.courseForImportDTO.id!, result.importRelations) - .pipe( - filter((res: HttpResponse>) => res.ok), - map((res: HttpResponse>) => res.body), - ) - .subscribe({ - next: (res: Array) => { - if (res.length > 0) { - this.alertService.success(`artemisApp.courseCompetency.importAll.success`, { noOfCompetencies: res.length, courseTitle: courseTitle }); - this.updateDataAfterImportAll(res); - } else { - this.alertService.warning(`artemisApp.courseCompetency.importAll.warning`, { courseTitle: courseTitle }); - } - }, - error: (res: HttpErrorResponse) => onError(this.alertService, res), - }); + async openImportAllModal() { + const modalRef = this.modalService.open(ImportAllCourseCompetenciesModalComponent, { + size: 'lg', + backdrop: 'static', }); + modalRef.componentInstance.courseId = signal(this.courseId); + const importResults: ImportAllCourseCompetenciesResult | undefined = await modalRef.result; + if (!importResults) { + return; + } + const courseTitle = importResults.course.title ?? ''; + try { + const importedCompetencies = await this.courseCompetencyApiService.importAllByCourseId(this.courseId, importResults.courseCompetencyImportOptions); + if (importedCompetencies.length) { + this.alertService.success(`artemisApp.courseCompetency.importAll.success`, { + noOfCompetencies: importedCompetencies.length, + courseTitle: courseTitle, + }); + this.updateDataAfterImportAll(importedCompetencies); + } else { + this.alertService.warning(`artemisApp.courseCompetency.importAll.warning`, { courseTitle: courseTitle }); + } + } catch (error) { + onError(this.alertService, error); + } } /** @@ -169,7 +162,7 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { .map((dto) => dto.tailRelations) .flat() .filter((element): element is CompetencyRelationDTO => !!element) - .map((dto) => dtoToCompetencyRelation(dto)); + .map(dtoToCompetencyRelation); this.competencies = this.competencies.concat(importedCompetencies); this.prerequisites = this.prerequisites.concat(importedPrerequisites); @@ -182,21 +175,17 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { * * @param relation the given competency relation */ - createRelation(relation: CompetencyRelation) { - this.courseCompetencyService - .createCompetencyRelation(relation, this.courseId) - .pipe( - filter((res) => res.ok), - map((res) => res.body), - ) - .subscribe({ - next: (relation) => { - if (relation) { - this.relations = this.relations.concat(dtoToCompetencyRelation(relation)); - } - }, - error: (res: HttpErrorResponse) => onError(this.alertService, res), + async createRelation(relation: CompetencyRelation) { + try { + const createdRelation = await this.courseCompetencyApiService.createCourseCompetencyRelation(this.courseId, { + headCompetencyId: relation.headCompetency?.id, + tailCompetencyId: relation.tailCompetency?.id, + relationType: relation.type, }); + this.relations = this.relations.concat(dtoToCompetencyRelation(createdRelation)); + } catch (error) { + onError(this.alertService, error); + } } /** @@ -225,13 +214,13 @@ export class CompetencyManagementComponent implements OnInit, OnDestroy { * * @param relationId the given id */ - private removeRelation(relationId: number) { - this.courseCompetencyService.removeCompetencyRelation(relationId, this.courseId).subscribe({ - next: () => { - this.relations = this.relations.filter((relation) => relation.id !== relationId); - }, - error: (res: HttpErrorResponse) => onError(this.alertService, res), - }); + private async removeRelation(relationId: number) { + try { + await this.courseCompetencyApiService.deleteCourseCompetencyRelation(this.courseId, relationId); + this.relations = this.relations.filter((relation) => relation.id !== relationId); + } catch (error) { + onError(this.alertService, error); + } } onRemoveCompetency(competencyId: number) { diff --git a/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.html b/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.html index 1be5b1aa75c3..c16d5c6bd2d9 100644 --- a/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.html +++ b/src/main/webapp/app/course/competencies/competency-management/competency-relation-graph.component.html @@ -38,6 +38,7 @@ > @for (relationType of competencyRelationType | keyvalue: keepOrder; track relationType) {