From 171b684e683fdb7c4d2ebcdf5d19a36deca088d2 Mon Sep 17 00:00:00 2001 From: Timor Morrien Date: Sun, 13 Oct 2024 02:34:48 +0200 Subject: [PATCH 1/3] Add course settings option to enable exercise features by the category of the exercise --- .../service/ExerciseDeletionService.java | 12 +- .../domain/settings/IrisChatSubSettings.java | 17 ++ .../IrisTextExerciseChatSubSettings.java | 17 ++ .../dto/IrisCombinedChatSubSettingsDTO.java | 2 +- ...ombinedTextExerciseChatSubSettingsDTO.java | 2 +- .../service/settings/IrisSettingsService.java | 152 +++++++++++++++++- .../settings/IrisSubSettingsService.java | 21 ++- .../ProgrammingExerciseRepository.java | 5 +- .../service/ProgrammingExerciseService.java | 9 ++ .../repository/TextExerciseRepository.java | 11 +- .../text/web/TextExerciseResource.java | 12 +- .../changelog/20241010101010_changelog.xml | 10 ++ .../resources/config/liquibase/master.xml | 1 + .../iris/settings/iris-sub-settings.model.ts | 3 +- ...-common-sub-settings-update.component.html | 19 +++ ...is-common-sub-settings-update.component.ts | 53 +++++- .../iris-settings-update.component.html | 4 + src/main/webapp/i18n/de/iris.json | 1 + src/main/webapp/i18n/en/iris.json | 1 + .../settings/IrisSettingsIntegrationTest.java | 106 +++++++++++- 20 files changed, 439 insertions(+), 19 deletions(-) create mode 100644 src/main/resources/config/liquibase/changelog/20241010101010_changelog.xml diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseDeletionService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseDeletionService.java index 73f31c2b73a5..5201d413fef1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseDeletionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ExerciseDeletionService.java @@ -4,6 +4,7 @@ import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; @@ -25,6 +26,7 @@ import de.tum.cit.aet.artemis.exam.repository.StudentExamRepository; import de.tum.cit.aet.artemis.exercise.domain.Exercise; import de.tum.cit.aet.artemis.exercise.repository.ExerciseRepository; +import de.tum.cit.aet.artemis.iris.service.settings.IrisSettingsService; import de.tum.cit.aet.artemis.lecture.domain.ExerciseUnit; import de.tum.cit.aet.artemis.lecture.repository.ExerciseUnitRepository; import de.tum.cit.aet.artemis.lecture.service.LectureUnitService; @@ -78,11 +80,14 @@ public class ExerciseDeletionService { private final CompetencyProgressService competencyProgressService; + private final Optional irisSettingsService; + public ExerciseDeletionService(ExerciseRepository exerciseRepository, ExerciseUnitRepository exerciseUnitRepository, ParticipationService participationService, ProgrammingExerciseService programmingExerciseService, ModelingExerciseService modelingExerciseService, QuizExerciseService quizExerciseService, TutorParticipationRepository tutorParticipationRepository, ExampleSubmissionService exampleSubmissionService, StudentExamRepository studentExamRepository, LectureUnitService lectureUnitService, PlagiarismResultRepository plagiarismResultRepository, TextExerciseService textExerciseService, - ChannelRepository channelRepository, ChannelService channelService, CompetencyProgressService competencyProgressService) { + ChannelRepository channelRepository, ChannelService channelService, CompetencyProgressService competencyProgressService, + Optional irisSettingsService) { this.exerciseRepository = exerciseRepository; this.participationService = participationService; this.programmingExerciseService = programmingExerciseService; @@ -98,6 +103,7 @@ public ExerciseDeletionService(ExerciseRepository exerciseRepository, ExerciseUn this.channelRepository = channelRepository; this.channelService = channelService; this.competencyProgressService = competencyProgressService; + this.irisSettingsService = irisSettingsService; } /** @@ -169,6 +175,10 @@ public void delete(long exerciseId, boolean deleteStudentReposBuildPlans, boolea lectureUnitService.removeLectureUnit(exerciseUnit); } + if (irisSettingsService.isPresent()) { + irisSettingsService.get().deleteSettingsFor(exercise); + } + // delete all plagiarism results belonging to this exercise plagiarismResultRepository.deletePlagiarismResultsByExerciseId(exerciseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java index bf2851ae7979..295d6a0042d0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java @@ -1,7 +1,11 @@ package de.tum.cit.aet.artemis.iris.domain.settings; +import java.util.SortedSet; +import java.util.TreeSet; + import jakarta.annotation.Nullable; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; @@ -24,6 +28,11 @@ public class IrisChatSubSettings extends IrisSubSettings { @Column(name = "rate_limit_timeframe_hours") private Integer rateLimitTimeframeHours; + @Nullable + @Column(name = "enabled_for_categories") + @Convert(converter = IrisListConverter.class) + private SortedSet enabledForCategories = new TreeSet<>(); + @Nullable public Integer getRateLimit() { return rateLimit; @@ -41,4 +50,12 @@ public Integer getRateLimitTimeframeHours() { public void setRateLimitTimeframeHours(@Nullable Integer rateLimitTimeframeHours) { this.rateLimitTimeframeHours = rateLimitTimeframeHours; } + + public SortedSet getEnabledForCategories() { + return enabledForCategories; + } + + public void setEnabledForCategories(SortedSet enabledForCategories) { + this.enabledForCategories = enabledForCategories; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisTextExerciseChatSubSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisTextExerciseChatSubSettings.java index ed090bbe892a..2b96a709a9ad 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisTextExerciseChatSubSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisTextExerciseChatSubSettings.java @@ -1,7 +1,11 @@ package de.tum.cit.aet.artemis.iris.domain.settings; +import java.util.SortedSet; +import java.util.TreeSet; + import jakarta.annotation.Nullable; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; @@ -23,6 +27,11 @@ public class IrisTextExerciseChatSubSettings extends IrisSubSettings { @Column(name = "rate_limit_timeframe_hours") private Integer rateLimitTimeframeHours; + @Nullable + @Column(name = "enabled_for_categories") + @Convert(converter = IrisListConverter.class) + private SortedSet enabledForCategories = new TreeSet<>(); + @Nullable public Integer getRateLimit() { return rateLimit; @@ -41,4 +50,12 @@ public void setRateLimitTimeframeHours(@Nullable Integer rateLimitTimeframeHours this.rateLimitTimeframeHours = rateLimitTimeframeHours; } + @Nullable + public SortedSet getEnabledForCategories() { + return enabledForCategories; + } + + public void setEnabledForCategories(@Nullable SortedSet enabledForCategories) { + this.enabledForCategories = enabledForCategories; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java index c5589e824507..9ca2784daf5d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java @@ -8,6 +8,6 @@ @JsonInclude(JsonInclude.Include.NON_EMPTY) public record IrisCombinedChatSubSettingsDTO(boolean enabled, Integer rateLimit, Integer rateLimitTimeframeHours, @Nullable Set allowedVariants, - @Nullable String selectedVariant) { + @Nullable String selectedVariant, @Nullable Set enabledForCategories) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedTextExerciseChatSubSettingsDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedTextExerciseChatSubSettingsDTO.java index 4f02a2d87720..2db220d79a44 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedTextExerciseChatSubSettingsDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedTextExerciseChatSubSettingsDTO.java @@ -8,6 +8,6 @@ @JsonInclude(JsonInclude.Include.NON_EMPTY) public record IrisCombinedTextExerciseChatSubSettingsDTO(boolean enabled, Integer rateLimit, Integer rateLimitTimeframeHours, @Nullable Set allowedVariants, - @Nullable String selectedVariant) { + @Nullable String selectedVariant, @Nullable Set enabledForCategories) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java index fb8820a4c08b..03993567e3b8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java @@ -8,8 +8,10 @@ import java.util.ArrayList; import java.util.Comparator; +import java.util.HashSet; import java.util.Objects; import java.util.Set; +import java.util.SortedSet; import java.util.TreeSet; import java.util.function.Supplier; @@ -18,6 +20,9 @@ import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.exception.AccessForbiddenAlertException; @@ -37,6 +42,10 @@ import de.tum.cit.aet.artemis.iris.domain.settings.IrisTextExerciseChatSubSettings; import de.tum.cit.aet.artemis.iris.dto.IrisCombinedSettingsDTO; import de.tum.cit.aet.artemis.iris.repository.IrisSettingsRepository; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; +import de.tum.cit.aet.artemis.text.domain.TextExercise; +import de.tum.cit.aet.artemis.text.repository.TextExerciseRepository; /** * Service for managing {@link IrisSettings}. @@ -55,10 +64,20 @@ public class IrisSettingsService { private final AuthorizationCheckService authCheckService; - public IrisSettingsService(IrisSettingsRepository irisSettingsRepository, IrisSubSettingsService irisSubSettingsService, AuthorizationCheckService authCheckService) { + private final ProgrammingExerciseRepository programmingExerciseRepository; + + private final ObjectMapper objectMapper; + + private final TextExerciseRepository textExerciseRepository; + + public IrisSettingsService(IrisSettingsRepository irisSettingsRepository, IrisSubSettingsService irisSubSettingsService, AuthorizationCheckService authCheckService, + ProgrammingExerciseRepository programmingExerciseRepository, ObjectMapper objectMapper, TextExerciseRepository textExerciseRepository) { this.irisSettingsRepository = irisSettingsRepository; this.irisSubSettingsService = irisSubSettingsService; this.authCheckService = authCheckService; + this.programmingExerciseRepository = programmingExerciseRepository; + this.objectMapper = objectMapper; + this.textExerciseRepository = textExerciseRepository; } /** @@ -248,6 +267,11 @@ private IrisGlobalSettings updateGlobalSettings(IrisGlobalSettings existingSetti * @return The updated course Iris settings */ private IrisCourseSettings updateCourseSettings(IrisCourseSettings existingSettings, IrisCourseSettings settingsUpdate) { + var oldEnabledForCategoriesExerciseChat = existingSettings.getIrisChatSettings() == null ? new TreeSet() + : existingSettings.getIrisChatSettings().getEnabledForCategories(); + var oldEnabledForCategoriesTextExerciseChat = existingSettings.getIrisTextExerciseChatSettings() == null ? new TreeSet() + : existingSettings.getIrisTextExerciseChatSettings().getEnabledForCategories(); + var parentSettings = getCombinedIrisGlobalSettings(); // @formatter:off existingSettings.setIrisChatSettings(irisSubSettingsService.update( @@ -276,9 +300,135 @@ private IrisCourseSettings updateCourseSettings(IrisCourseSettings existingSetti )); // @formatter:on + // Automatically update the exercise settings when the enabledForCategories is changed + var newEnabledForCategoriesExerciseChat = existingSettings.getIrisChatSettings() == null ? new TreeSet() + : existingSettings.getIrisChatSettings().getEnabledForCategories(); + if (!Objects.equals(oldEnabledForCategoriesExerciseChat, newEnabledForCategoriesExerciseChat)) { + programmingExerciseRepository.findAllWithCategoriesByCourseId(existingSettings.getCourse().getId()) + .forEach(exercise -> setEnabledForExerciseByCategories(exercise, oldEnabledForCategoriesExerciseChat, newEnabledForCategoriesExerciseChat)); + } + + var newEnabledForCategoriesTextExerciseChat = existingSettings.getIrisTextExerciseChatSettings() == null ? new TreeSet() + : existingSettings.getIrisTextExerciseChatSettings().getEnabledForCategories(); + if (!Objects.equals(oldEnabledForCategoriesTextExerciseChat, newEnabledForCategoriesTextExerciseChat)) { + textExerciseRepository.findAllWithCategoriesByCourseId(existingSettings.getCourse().getId()) + .forEach(exercise -> setEnabledForExerciseByCategories(exercise, oldEnabledForCategoriesTextExerciseChat, newEnabledForCategoriesTextExerciseChat)); + } + return irisSettingsRepository.save(existingSettings); } + /** + * Set the enabled status for an exercise based on it's categories. + * Compares the old and new enabled categories, reads the exercise categories, + * and updates the Iris chat settings accordingly if the new enabled categories match any of the exercise categories. + * This method is used when the enabled categories of the course settings are updated. + * + * @param exercise The exercise to update the enabled status for + * @param oldEnabledForCategories The old enabled categories + * @param newEnabledForCategories The new enabled categories + */ + public void setEnabledForExerciseByCategories(Exercise exercise, SortedSet oldEnabledForCategories, SortedSet newEnabledForCategories) { + var removedCategories = new TreeSet<>(oldEnabledForCategories == null ? Set.of() : oldEnabledForCategories); + removedCategories.removeAll(newEnabledForCategories); + var categories = getCategoryNames(exercise.getCategories()); + + if (newEnabledForCategories != null && categories.stream().anyMatch(newEnabledForCategories::contains)) { + var exerciseSettings = getRawIrisSettingsFor(exercise); + if (exercise instanceof ProgrammingExercise) { + exerciseSettings.getIrisChatSettings().setEnabled(true); + } + else if (exercise instanceof TextExercise) { + exerciseSettings.getIrisTextExerciseChatSettings().setEnabled(true); + } + irisSettingsRepository.save(exerciseSettings); + } + else if (categories.stream().anyMatch(removedCategories::contains)) { + var exerciseSettings = getRawIrisSettingsFor(exercise); + if (exercise instanceof ProgrammingExercise) { + exerciseSettings.getIrisChatSettings().setEnabled(false); + } + else if (exercise instanceof TextExercise) { + exerciseSettings.getIrisTextExerciseChatSettings().setEnabled(false); + } + irisSettingsRepository.save(exerciseSettings); + } + } + + /** + * Set the enabled status for an exercise based on its categories. + * Reads the exercise categories and updates the Iris chat settings accordingly if the enabled categories match any of the exercise categories. + * This method is used when the categories of an exercise are updated. + * + * @param exercise The exercise to update the enabled status for + * @param oldExerciseCategories The old exercise categories + */ + public void setEnabledForExerciseByCategories(Exercise exercise, Set oldExerciseCategories) { + var oldCategories = getCategoryNames(oldExerciseCategories); + var newCategories = getCategoryNames(exercise.getCategories()); + if (oldCategories.isEmpty() && newCategories.isEmpty()) { + return; + } + + var course = exercise.getCourseViaExerciseGroupOrCourseMember(); + var courseSettings = getRawIrisSettingsFor(course); + + Set enabledForCategories; + if (exercise instanceof ProgrammingExercise) { + enabledForCategories = courseSettings.getIrisChatSettings().getEnabledForCategories(); + } + else if (exercise instanceof TextExercise) { + enabledForCategories = courseSettings.getIrisTextExerciseChatSettings().getEnabledForCategories(); + } + else { + return; + } + if (enabledForCategories == null) { + return; + } + + if (newCategories.stream().anyMatch(enabledForCategories::contains)) { + var exerciseSettings = getRawIrisSettingsFor(exercise); + if (exercise instanceof ProgrammingExercise) { + exerciseSettings.getIrisChatSettings().setEnabled(true); + } + else if (exercise instanceof TextExercise) { + exerciseSettings.getIrisTextExerciseChatSettings().setEnabled(true); + } + irisSettingsRepository.save(exerciseSettings); + } + else if (oldCategories.stream().anyMatch(enabledForCategories::contains)) { + var exerciseSettings = getRawIrisSettingsFor(exercise); + if (exercise instanceof ProgrammingExercise) { + exerciseSettings.getIrisChatSettings().setEnabled(false); + } + else if (exercise instanceof TextExercise) { + exerciseSettings.getIrisTextExerciseChatSettings().setEnabled(false); + } + irisSettingsRepository.save(exerciseSettings); + } + } + + /** + * Convert the category JSON strings of an exercise to a set of category names. + * + * @param exerciseCategories The set of category JSON strings + * @return The set of category names + */ + private Set getCategoryNames(Set exerciseCategories) { + var categories = new HashSet(); + for (var categoryJson : exerciseCategories) { + try { + var category = objectMapper.readTree(categoryJson); + categories.add(category.get("category").asText()); + } + catch (JsonProcessingException e) { + return new HashSet<>(); + } + } + return categories; + } + /** * Helper method to update exercise Iris settings. * diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java index 4e804701151a..0cf5af873aad 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java @@ -17,6 +17,7 @@ import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.iris.domain.settings.IrisChatSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisCompetencyGenerationSubSettings; +import de.tum.cit.aet.artemis.iris.domain.settings.IrisCourseSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisExerciseSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisLectureIngestionSubSettings; import de.tum.cit.aet.artemis.iris.domain.settings.IrisSettings; @@ -71,6 +72,10 @@ public IrisChatSubSettings update(IrisChatSubSettings currentSettings, IrisChatS if (settingsType == IrisSettingsType.EXERCISE || authCheckService.isAdmin()) { currentSettings.setEnabled(newSettings.isEnabled()); } + if (settingsType == IrisSettingsType.COURSE) { + var enabledForCategories = newSettings.getEnabledForCategories(); + currentSettings.setEnabledForCategories(enabledForCategories); + } if (authCheckService.isAdmin()) { currentSettings.setRateLimit(newSettings.getRateLimit()); currentSettings.setRateLimitTimeframeHours(newSettings.getRateLimitTimeframeHours()); @@ -104,6 +109,10 @@ public IrisTextExerciseChatSubSettings update(IrisTextExerciseChatSubSettings cu if (settingsType == IrisSettingsType.EXERCISE || authCheckService.isAdmin()) { currentSettings.setEnabled(newSettings.isEnabled()); } + if (settingsType == IrisSettingsType.COURSE) { + var enabledForCategories = newSettings.getEnabledForCategories(); + currentSettings.setEnabledForCategories(enabledForCategories); + } if (authCheckService.isAdmin()) { currentSettings.setRateLimit(newSettings.getRateLimit()); currentSettings.setRateLimitTimeframeHours(newSettings.getRateLimitTimeframeHours()); @@ -229,7 +238,8 @@ public IrisCombinedTextExerciseChatSubSettingsDTO combineTextExerciseChatSetting var rateLimit = getCombinedRateLimit(settingsList); var allowedVariants = !minimal ? getCombinedAllowedVariants(settingsList, IrisSettings::getIrisChatSettings) : null; var selectedVariant = !minimal ? getCombinedSelectedVariant(settingsList, IrisSettings::getIrisChatSettings) : null; - return new IrisCombinedTextExerciseChatSubSettingsDTO(enabled, rateLimit, null, allowedVariants, selectedVariant); + var enabledForCategories = !minimal ? getCombinedEnabledForCategories(settingsList, IrisSettings::getIrisChatSettings) : null; + return new IrisCombinedTextExerciseChatSubSettingsDTO(enabled, rateLimit, null, allowedVariants, selectedVariant, enabledForCategories); } /** @@ -246,7 +256,8 @@ public IrisCombinedChatSubSettingsDTO combineChatSettings(ArrayList settingsList, Funct return settingsList.stream().filter(Objects::nonNull).map(subSettingsFunction).filter(Objects::nonNull).map(IrisSubSettings::getSelectedVariant) .filter(model -> model != null && !model.isBlank()).reduce((first, second) -> second).orElse(null); } + + private Set getCombinedEnabledForCategories(List settingsList, Function subSettingsFunction) { + return settingsList.stream().filter(Objects::nonNull).filter(settings -> settings instanceof IrisCourseSettings).map(subSettingsFunction).filter(Objects::nonNull) + .map(IrisChatSubSettings::getEnabledForCategories).filter(Objects::nonNull).filter(models -> !models.isEmpty()).reduce((first, second) -> second) + .orElse(new TreeSet<>()); + } } 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 579d714b18a8..d4cde4abb323 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 @@ -108,7 +108,7 @@ Optional findWithTemplateAndSolutionParticipationTeamAssign @EntityGraph(type = LOAD, attributePaths = "auxiliaryRepositories") Optional findWithAuxiliaryRepositoriesById(long exerciseId); - @EntityGraph(type = LOAD, attributePaths = { "auxiliaryRepositories", "competencies", "buildConfig" }) + @EntityGraph(type = LOAD, attributePaths = { "auxiliaryRepositories", "competencies", "buildConfig", "categories" }) Optional findWithAuxiliaryRepositoriesCompetenciesAndBuildConfigById(long exerciseId); @EntityGraph(type = LOAD, attributePaths = "submissionPolicy") @@ -118,6 +118,9 @@ Optional findWithTemplateAndSolutionParticipationTeamAssign List findAllByCourseId(Long courseId); + @EntityGraph(type = LOAD, attributePaths = { "categories" }) + List findAllWithCategoriesByCourseId(Long courseId); + @EntityGraph(type = LOAD, attributePaths = "submissionPolicy") List findWithSubmissionPolicyByProjectKey(String projectKey); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java index 0cbf73da21ff..113035b7b77b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java @@ -18,6 +18,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -338,6 +339,11 @@ public ProgrammingExercise createProgrammingExercise(ProgrammingExercise program // Step 12d: Update student competency progress competencyProgressService.updateProgressByLearningObjectAsync(savedProgrammingExercise); + // Step 13: Set Iris settings + if (irisSettingsService.isPresent()) { + irisSettingsService.get().setEnabledForExerciseByCategories(savedProgrammingExercise, new HashSet<>()); + } + return programmingExerciseRepository.saveForCreation(savedProgrammingExercise); } @@ -619,6 +625,9 @@ public ProgrammingExercise updateProgrammingExercise(ProgrammingExercise program competencyProgressService.updateProgressForUpdatedLearningObjectAsync(programmingExerciseBeforeUpdate, Optional.of(updatedProgrammingExercise)); + irisSettingsService + .ifPresent(settingsService -> settingsService.setEnabledForExerciseByCategories(savedProgrammingExercise, programmingExerciseBeforeUpdate.getCategories())); + return savedProgrammingExercise; } 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 3171fa825b63..5b94c29fca9a 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 @@ -35,8 +35,8 @@ public interface TextExerciseRepository extends ArtemisJpaRepository findByCourseIdWithCategories(@Param("courseId") long courseId); - @EntityGraph(type = LOAD, attributePaths = { "competencies" }) - Optional findWithEagerCompetenciesById(long exerciseId); + @EntityGraph(type = LOAD, attributePaths = { "competencies", "categories" }) + Optional findWithEagerCompetenciesAndCategoriesById(long exerciseId); @EntityGraph(type = LOAD, attributePaths = { "teamAssignmentConfig", "categories", "competencies" }) Optional findWithEagerTeamAssignmentConfigAndCategoriesAndCompetenciesById(long exerciseId); @@ -110,8 +110,8 @@ default TextExercise findWithGradingCriteriaByIdElseThrow(long exerciseId) { } @NotNull - default TextExercise findWithEagerCompetenciesByIdElseThrow(long exerciseId) { - return getValueElseThrow(findWithEagerCompetenciesById(exerciseId), exerciseId); + default TextExercise findWithEagerCompetenciesAndCategoriesByIdElseThrow(long exerciseId) { + return getValueElseThrow(findWithEagerCompetenciesAndCategoriesById(exerciseId), exerciseId); } @NotNull @@ -128,4 +128,7 @@ default TextExercise findByIdWithExampleSubmissionsAndResultsAndGradingCriteriaE default TextExercise findByIdWithStudentParticipationsAndSubmissionsElseThrow(long exerciseId) { return getValueElseThrow(findWithStudentParticipationsAndSubmissionsById(exerciseId), exerciseId); } + + @EntityGraph(type = LOAD, attributePaths = { "categories" }) + List findAllWithCategoriesByCourseId(Long courseId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java index 8f98cd911c51..58b63332e519 100644 --- a/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/text/web/TextExerciseResource.java @@ -77,6 +77,7 @@ import de.tum.cit.aet.artemis.exercise.service.ExerciseDateService; import de.tum.cit.aet.artemis.exercise.service.ExerciseDeletionService; import de.tum.cit.aet.artemis.exercise.service.ExerciseService; +import de.tum.cit.aet.artemis.iris.service.settings.IrisSettingsService; import de.tum.cit.aet.artemis.plagiarism.domain.text.TextPlagiarismResult; import de.tum.cit.aet.artemis.plagiarism.dto.PlagiarismResultDTO; import de.tum.cit.aet.artemis.plagiarism.repository.PlagiarismResultRepository; @@ -154,6 +155,8 @@ public class TextExerciseResource { private final CompetencyProgressService competencyProgressService; + private final Optional irisSettingsService; + public TextExerciseResource(TextExerciseRepository textExerciseRepository, TextExerciseService textExerciseService, FeedbackRepository feedbackRepository, ExerciseDeletionService exerciseDeletionService, PlagiarismResultRepository plagiarismResultRepository, UserRepository userRepository, AuthorizationCheckService authCheckService, CourseService courseService, StudentParticipationRepository studentParticipationRepository, @@ -162,7 +165,7 @@ public TextExerciseResource(TextExerciseRepository textExerciseRepository, TextE GradingCriterionRepository gradingCriterionRepository, TextBlockRepository textBlockRepository, GroupNotificationScheduleService groupNotificationScheduleService, InstanceMessageSendService instanceMessageSendService, PlagiarismDetectionService plagiarismDetectionService, CourseRepository courseRepository, ChannelService channelService, ChannelRepository channelRepository, Optional athenaModuleService, - CompetencyProgressService competencyProgressService) { + CompetencyProgressService competencyProgressService, Optional irisSettingsService) { this.feedbackRepository = feedbackRepository; this.exerciseDeletionService = exerciseDeletionService; this.plagiarismResultRepository = plagiarismResultRepository; @@ -188,6 +191,7 @@ public TextExerciseResource(TextExerciseRepository textExerciseRepository, TextE this.channelRepository = channelRepository; this.athenaModuleService = athenaModuleService; this.competencyProgressService = competencyProgressService; + this.irisSettingsService = irisSettingsService; } /** @@ -229,6 +233,8 @@ public ResponseEntity createTextExercise(@RequestBody TextExercise groupNotificationScheduleService.checkNotificationsForNewExerciseAsync(textExercise); competencyProgressService.updateProgressByLearningObjectAsync(result); + irisSettingsService.ifPresent(iss -> iss.setEnabledForExerciseByCategories(result, new HashSet<>())); + return ResponseEntity.created(new URI("/api/text-exercises/" + result.getId())).body(result); } @@ -259,7 +265,7 @@ public ResponseEntity updateTextExercise(@RequestBody TextExercise // Check that the user is authorized to update the exercise var user = userRepository.getUserWithGroupsAndAuthorities(); // Important: use the original exercise for permission check - final TextExercise textExerciseBeforeUpdate = textExerciseRepository.findWithEagerCompetenciesByIdElseThrow(textExercise.getId()); + final TextExercise textExerciseBeforeUpdate = textExerciseRepository.findWithEagerCompetenciesAndCategoriesByIdElseThrow(textExercise.getId()); authCheckService.checkHasAtLeastRoleForExerciseElseThrow(Role.EDITOR, textExerciseBeforeUpdate, user); // Forbid changing the course the exercise belongs to. @@ -288,6 +294,8 @@ public ResponseEntity updateTextExercise(@RequestBody TextExercise competencyProgressService.updateProgressForUpdatedLearningObjectAsync(textExerciseBeforeUpdate, Optional.of(textExercise)); + irisSettingsService.ifPresent(iss -> iss.setEnabledForExerciseByCategories(textExercise, textExerciseBeforeUpdate.getCategories())); + return ResponseEntity.ok(updatedTextExercise); } diff --git a/src/main/resources/config/liquibase/changelog/20241010101010_changelog.xml b/src/main/resources/config/liquibase/changelog/20241010101010_changelog.xml new file mode 100644 index 000000000000..9bbfc1d0b383 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241010101010_changelog.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index d496528a13ec..8e81c45faf5f 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -28,6 +28,7 @@ + diff --git a/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts b/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts index adb58a22b693..2225f8d6bbe1 100644 --- a/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts +++ b/src/main/webapp/app/entities/iris/settings/iris-sub-settings.model.ts @@ -13,10 +13,9 @@ export abstract class IrisSubSettings implements BaseEntity { enabled = false; allowedVariants?: string[]; selectedVariant?: string; + enabledForCategories?: string[]; } -// TODO: Split into ProgrammingExerciseChatSubSettings and CourseChatSubSettings -// TODO: Each feature should probably get its own rate limit instead of sharing one export class IrisChatSubSettings extends IrisSubSettings { type = IrisSubSettingsType.CHAT; rateLimit?: number; diff --git a/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.html b/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.html index 95238efecaf9..317a0c4cb265 100644 --- a/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.html +++ b/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.html @@ -24,6 +24,25 @@ jhiTranslate="artemisApp.iris.settings.subSettings.enabled.off" > +@if (settingsType === COURSE && (subSettings?.type === CHAT || subSettings?.type === TEXT_EXERCISE_CHAT)) { +

+
+
+ @for (category of categories; track category) { +
+ + +
+ } +
+
+}

: @if (parentSubSettings) { diff --git a/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.ts b/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.ts index ba5bd6573691..0d4f1898653b 100644 --- a/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.ts +++ b/src/main/webapp/app/iris/settings/iris-settings-update/iris-common-sub-settings-update/iris-common-sub-settings-update.component.ts @@ -6,6 +6,11 @@ import { ButtonType } from 'app/shared/components/button.component'; import { faTrash } from '@fortawesome/free-solid-svg-icons'; import { IrisSettingsType } from 'app/entities/iris/settings/iris-settings.model'; import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { onError } from 'app/shared/util/global.utils'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { AlertService } from 'app/core/util/alert.service'; @Component({ selector: 'jhi-iris-common-sub-settings-update', @@ -21,6 +26,9 @@ export class IrisCommonSubSettingsUpdateComponent implements OnInit, OnChanges { @Input() settingsType: IrisSettingsType; + @Input() + courseId?: number; + @Output() onChanges = new EventEmitter(); @@ -34,23 +42,34 @@ export class IrisCommonSubSettingsUpdateComponent implements OnInit, OnChanges { enabled: boolean; + categories: string[] = []; + // Settings types EXERCISE = IrisSettingsType.EXERCISE; COURSE = IrisSettingsType.COURSE; + TEXT_EXERCISE_CHAT = IrisSubSettingsType.TEXT_EXERCISE_CHAT; + CHAT = IrisSubSettingsType.CHAT; // Button types WARNING = ButtonType.WARNING; // Icons faTrash = faTrash; + protected readonly IrisSubSettings = IrisSubSettings; + protected readonly IrisSubSettingsType = IrisSubSettingsType; + constructor( accountService: AccountService, private irisSettingsService: IrisSettingsService, + private courseManagementService: CourseManagementService, + private exerciseService: ExerciseService, + private alertService: AlertService, ) { this.isAdmin = accountService.isAdmin(); } ngOnInit() { this.enabled = this.subSettings?.enabled ?? false; + this.loadCategories(); this.loadVariants(); this.inheritAllowedVariants = !!(!this.subSettings?.allowedVariants && this.parentSubSettings); } @@ -64,6 +83,23 @@ export class IrisCommonSubSettingsUpdateComponent implements OnInit, OnChanges { } } + loadCategories() { + if (this.settingsType === this.COURSE) { + this.courseManagementService.findAllCategoriesOfCourse(this.courseId!).subscribe({ + next: (response: HttpResponse) => { + this.categories = this.exerciseService + .convertExerciseCategoriesAsStringFromServer(response.body!) + .map((category) => category.category) + .filter((category) => category !== undefined) + .map((category) => category!); + // Remove duplicate categories + this.categories = Array.from(new Set(this.categories)); + }, + error: (error: HttpErrorResponse) => onError(this.alertService, error), + }); + } + } + loadVariants(): void { if (!this.subSettings?.type) { return; @@ -123,15 +159,28 @@ export class IrisCommonSubSettingsUpdateComponent implements OnInit, OnChanges { } } + onCategorySelectionChange(category: string) { + if (!this.subSettings) { + return; + } + if (!this.subSettings.enabledForCategories) { + this.subSettings.enabledForCategories = []; + } + if (this.subSettings.enabledForCategories?.includes(category)) { + this.subSettings.enabledForCategories = this.subSettings.enabledForCategories!.filter((c) => c !== category); + } else { + this.subSettings.enabledForCategories = [...(this.subSettings.enabledForCategories ?? []), category]; + } + } + get inheritDisabled() { if (this.parentSubSettings) { return !this.parentSubSettings.enabled; } return false; } + get isSettingsSwitchDisabled() { return this.inheritDisabled || (!this.isAdmin && this.settingsType !== this.EXERCISE); } - protected readonly IrisSubSettings = IrisSubSettings; - protected readonly IrisSubSettingsType = IrisSubSettingsType; } diff --git a/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html b/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html index 8865d90aee42..a2b7109c01c2 100644 --- a/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html +++ b/src/main/webapp/app/iris/settings/iris-settings-update/iris-settings-update.component.html @@ -17,6 +17,7 @@

[subSettings]="irisSettings?.irisChatSettings" [parentSubSettings]="parentIrisSettings?.irisChatSettings" [settingsType]="settingsType" + [courseId]="courseId" (onChanges)="isDirty = true" /> @@ -27,6 +28,7 @@

@@ -38,6 +40,7 @@


@@ -62,6 +65,7 @@

diff --git a/src/main/webapp/i18n/de/iris.json b/src/main/webapp/i18n/de/iris.json index 0a687f629ebe..70627d2052c0 100644 --- a/src/main/webapp/i18n/de/iris.json +++ b/src/main/webapp/i18n/de/iris.json @@ -33,6 +33,7 @@ "hestiaSettings": "Hestia Einstellungen", "competencyGenerationSettings": "Kompetenzgenerierung Einstellungen", "enabled-disabled": "Aktiviert/Deaktiviert", + "enabledForCategories": "Automatisch aktivieren für Kategorien", "variants": { "title": "Varianten", "allowedVariants": { diff --git a/src/main/webapp/i18n/en/iris.json b/src/main/webapp/i18n/en/iris.json index 8bf0df8dafdc..d3ba981c4419 100644 --- a/src/main/webapp/i18n/en/iris.json +++ b/src/main/webapp/i18n/en/iris.json @@ -33,6 +33,7 @@ "hestiaSettings": "Hestia Settings", "competencyGenerationSettings": "Competency Generation Settings", "enabled-disabled": "Enabled/Disabled", + "enabledForCategories": "Automatically enable for categories", "variants": { "title": "Variants", "allowedVariants": { diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/settings/IrisSettingsIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/settings/IrisSettingsIntegrationTest.java index 4ead87fa669a..8e877d1e50e2 100644 --- a/src/test/java/de/tum/cit/aet/artemis/iris/settings/IrisSettingsIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/iris/settings/IrisSettingsIntegrationTest.java @@ -2,15 +2,26 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.time.ZonedDateTime; import java.util.HashSet; +import java.util.List; +import java.util.SortedSet; import java.util.TreeSet; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.iris.AbstractIrisIntegrationTest; import de.tum.cit.aet.artemis.iris.domain.settings.IrisChatSubSettings; @@ -23,6 +34,9 @@ import de.tum.cit.aet.artemis.iris.repository.IrisSettingsRepository; import de.tum.cit.aet.artemis.iris.repository.IrisSubSettingsRepository; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.service.aeolus.AeolusTemplateService; +import de.tum.cit.aet.artemis.text.domain.TextExercise; +import de.tum.cit.aet.artemis.text.util.TextExerciseUtilService; class IrisSettingsIntegrationTest extends AbstractIrisIntegrationTest { @@ -34,15 +48,58 @@ class IrisSettingsIntegrationTest extends AbstractIrisIntegrationTest { @Autowired private IrisSettingsRepository irisSettingsRepository; + @Autowired + private AeolusTemplateService aeolusTemplateService; + + @Autowired + private TextExerciseUtilService textExerciseUtilService; + private Course course; private ProgrammingExercise programmingExercise; + private TextExercise textExercise; + + private static Stream getCourseSettingsCategoriesSource() { + return Stream.of(Arguments.of(List.of("COURSE"), List.of(List.of("category1")), false), + Arguments.of(List.of("COURSE", "EXERCISE"), List.of(List.of("category1"), List.of("category1")), true), + Arguments.of(List.of("EXERCISE", "COURSE"), List.of(List.of("category1"), List.of("category1")), true), + Arguments.of(List.of("EXERCISE"), List.of(List.of("category1")), false), + Arguments.of(List.of("EXERCISE", "COURSE", "EXERCISE"), List.of(List.of("category1"), List.of("category1"), List.of()), false), + Arguments.of(List.of("COURSE", "EXERCISE", "COURSE"), List.of(List.of("category1"), List.of("category1"), List.of()), false), + Arguments.of(List.of("EXERCISE", "COURSE", "EXERCISE"), List.of(List.of("category1", "category2"), List.of("category1"), List.of("category2")), false), + Arguments.of(List.of("EXERCISE", "COURSE", "EXERCISE"), List.of(List.of("category1", "category2"), List.of("category1"), List.of("category1")), true), + Arguments.of(List.of("EXERCISE", "COURSE", "EXERCISE"), List.of(List.of("category1"), List.of("category2"), List.of("category2")), true), + Arguments.of(List.of("COURSE", "EXERCISE", "COURSE"), List.of(List.of("category1", "category2"), List.of("category1"), List.of("category2")), false), + Arguments.of(List.of("COURSE", "EXERCISE", "COURSE"), List.of(List.of("category1", "category2"), List.of("category1"), List.of("category1")), true)); + } + @BeforeEach - void initTestCase() { + void initTestCase() throws JsonProcessingException { userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 1); - course = programmingExerciseUtilService.addCourseWithOneProgrammingExerciseAndTestCases(); + course = programmingExerciseUtilService.addCourseWithOneProgrammingExercise(); programmingExercise = exerciseUtilService.getFirstExerciseWithType(course, ProgrammingExercise.class); + var projectKey1 = programmingExercise.getProjectKey(); + programmingExercise.setTestRepositoryUri(localVCBaseUrl + "/git/" + projectKey1 + "/" + projectKey1.toLowerCase() + "-tests.git"); + programmingExercise.getBuildConfig().setBuildPlanConfiguration(new ObjectMapper().writeValueAsString(aeolusTemplateService.getDefaultWindfileFor(programmingExercise))); + programmingExerciseBuildConfigRepository.save(programmingExercise.getBuildConfig()); + programmingExerciseRepository.save(programmingExercise); + programmingExercise = programmingExerciseRepository.findWithAllParticipationsAndBuildConfigById(programmingExercise.getId()).orElseThrow(); + + var templateRepositorySlug = localVCLocalCITestService.getRepositorySlug(projectKey1, "exercise"); + var templateParticipation = programmingExercise.getTemplateParticipation(); + templateParticipation.setRepositoryUri(localVCBaseUrl + "/git/" + projectKey1 + "/" + templateRepositorySlug + ".git"); + templateProgrammingExerciseParticipationRepository.save(templateParticipation); + var solutionRepositorySlug = localVCLocalCITestService.getRepositorySlug(projectKey1, "solution"); + var solutionParticipation = programmingExercise.getSolutionParticipation(); + solutionParticipation.setRepositoryUri(localVCBaseUrl + "/git/" + projectKey1 + "/" + solutionRepositorySlug + ".git"); + solutionProgrammingExerciseParticipationRepository.save(solutionParticipation); + + // Text Exercise + ZonedDateTime pastReleaseDate = ZonedDateTime.now().minusDays(5); + ZonedDateTime pastDueDate = ZonedDateTime.now().minusDays(3); + ZonedDateTime pastAssessmentDueDate = ZonedDateTime.now().minusDays(2); + textExercise = textExerciseUtilService.createIndividualTextExercise(course, pastReleaseDate, pastDueDate, pastAssessmentDueDate); } @Test @@ -169,6 +226,51 @@ void updateCourseSettings3() throws Exception { "irisLectureIngestionSettings.id", "irisCompetencyGenerationSettings.id", "irisCompetencyGenerationSettings.template.id").isEqualTo(courseSettings); } + /** + * This test check if exercises get correctly enabled and disabled based on the categories in the course settings. + * + * @param operations List of operations to perform on the settings. Possible values are "COURSE" and "EXERCISE". + * @param categories List of categories to set for the course and exercise settings. + * @param exerciseEnabled Expected value of the exercise enabled flag. + * @throws Exception If something request fails. + */ + @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") + @MethodSource("getCourseSettingsCategoriesSource") + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void updateCourseSettingsCategories(List operations, List> categories, boolean exerciseEnabled) throws Exception { + activateIrisGlobally(); + activateIrisFor(course); + course = courseRepository.findByIdElseThrow(course.getId()); + + for (int i = 0; i < operations.size(); i++) { + String operation = operations.get(i); + SortedSet category = new TreeSet<>(categories.get(i)); + if (operation.equals("COURSE")) { + var loadedSettings = request.get("/api/courses/" + course.getId() + "/raw-iris-settings", HttpStatus.OK, IrisSettings.class); + loadedSettings.getIrisChatSettings().setEnabledForCategories(category); + loadedSettings.getIrisTextExerciseChatSettings().setEnabledForCategories(category); + request.putWithResponseBody("/api/courses/" + course.getId() + "/raw-iris-settings", loadedSettings, IrisSettings.class, HttpStatus.OK); + } + else if (operation.equals("EXERCISE")) { + programmingExercise = programmingExerciseRepository.findWithAllParticipationsAndBuildConfigById(programmingExercise.getId()).orElseThrow(); + programmingExercise.setCategories(category.stream().map(cat -> "{\"color\":\"#6ae8ac\",\"category\":\"" + cat + "\"}").collect(Collectors.toSet())); + request.putWithResponseBody("/api/programming-exercises", programmingExercise, ProgrammingExercise.class, HttpStatus.OK); + + textExercise = (TextExercise) exerciseRepository.findByIdElseThrow(textExercise.getId()); + textExercise.setCategories(category.stream().map(cat -> "{\"color\":\"#6ae8ac\",\"category\":\"" + cat + "\"}").collect(Collectors.toSet())); + request.putWithResponseBody("/api/text-exercises", textExercise, TextExercise.class, HttpStatus.OK); + } + } + + // Load programming exercise Iris settings + var loadedSettings1 = request.get("/api/exercises/" + programmingExercise.getId() + "/raw-iris-settings", HttpStatus.OK, IrisSettings.class); + assertThat(loadedSettings1.getIrisChatSettings().isEnabled()).isEqualTo(exerciseEnabled); + + // Load text exercise Iris settings + var loadedSettings2 = request.get("/api/exercises/" + textExercise.getId() + "/raw-iris-settings", HttpStatus.OK, IrisSettings.class); + assertThat(loadedSettings2.getIrisTextExerciseChatSettings().isEnabled()).isEqualTo(exerciseEnabled); + } + @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void getMissingSettingsForProgrammingExercise() throws Exception { From 9a2dc2cd5caaee4997b0156ee64ef326d98eb6cd Mon Sep 17 00:00:00 2001 From: Timor Morrien Date: Sun, 13 Oct 2024 03:15:54 +0200 Subject: [PATCH 2/3] Add client test --- ...mmon-sub-settings-update.component.spec.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/test/javascript/spec/component/iris/settings/iris-common-sub-settings-update.component.spec.ts b/src/test/javascript/spec/component/iris/settings/iris-common-sub-settings-update.component.spec.ts index 2b36487e4576..d43efbcc98e4 100644 --- a/src/test/javascript/spec/component/iris/settings/iris-common-sub-settings-update.component.spec.ts +++ b/src/test/javascript/spec/component/iris/settings/iris-common-sub-settings-update.component.spec.ts @@ -11,6 +11,9 @@ import { IrisSettingsType } from 'app/entities/iris/settings/iris-settings.model import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { IrisSettingsService } from 'app/iris/settings/shared/iris-settings.service'; import { of } from 'rxjs'; +import { ExerciseCategory } from 'app/entities/exercise-category.model'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { HttpResponse } from '@angular/common/http'; function baseSettings() { const irisSubSettings = new IrisChatSubSettings(); @@ -23,10 +26,20 @@ function baseSettings() { return irisSubSettings; } +function mockCategories() { + return [ + // Convert ExerciseCategory to json string + JSON.stringify(new ExerciseCategory('category1', '0xff0000')), + JSON.stringify(new ExerciseCategory('category2', '0x00ff00')), + JSON.stringify(new ExerciseCategory('category3', '0x0000ff')), + ]; +} + describe('IrisCommonSubSettingsUpdateComponent Component', () => { let comp: IrisCommonSubSettingsUpdateComponent; let fixture: ComponentFixture; let getVariantsSpy: jest.SpyInstance; + let getCategoriesSpy: jest.SpyInstance; beforeEach(() => { TestBed.configureTestingModule({ @@ -36,7 +49,9 @@ describe('IrisCommonSubSettingsUpdateComponent Component', () => { .compileComponents() .then(() => { const irisSettingsService = TestBed.inject(IrisSettingsService); + const courseManagementService = TestBed.inject(CourseManagementService); getVariantsSpy = jest.spyOn(irisSettingsService, 'getVariantsForFeature').mockReturnValue(of(mockVariants())); + getCategoriesSpy = jest.spyOn(courseManagementService, 'findAllCategoriesOfCourse').mockReturnValue(of(new HttpResponse({ body: mockCategories() }))); }); fixture = TestBed.createComponent(IrisCommonSubSettingsUpdateComponent); comp = fixture.componentInstance; @@ -181,4 +196,27 @@ describe('IrisCommonSubSettingsUpdateComponent Component', () => { expect(comp.enabled).toBeFalse(); expect(comp.allowedVariants).toEqual(newModels); }); + + it('enable categories', () => { + comp.subSettings = baseSettings(); + comp.parentSubSettings = baseSettings(); + comp.parentSubSettings.enabled = false; + comp.isAdmin = true; + comp.settingsType = IrisSettingsType.COURSE; + comp.availableVariants = mockVariants(); + fixture.detectChanges(); + + expect(getCategoriesSpy).toHaveBeenCalledOnce(); + + comp.onCategorySelectionChange('category1'); + expect(comp.subSettings!.enabledForCategories).toEqual(['category1']); + comp.onCategorySelectionChange('category2'); + expect(comp.subSettings!.enabledForCategories).toEqual(['category1', 'category2']); + comp.onCategorySelectionChange('category1'); + expect(comp.subSettings!.enabledForCategories).toEqual(['category2']); + + comp.subSettings = undefined; + comp.onCategorySelectionChange('category1'); + expect(comp.subSettings).toBeUndefined(); + }); }); From 6812ddb27da4f22c405585faeb8c3c9b44ef0ab1 Mon Sep 17 00:00:00 2001 From: Timor Morrien Date: Tue, 15 Oct 2024 12:57:55 +0200 Subject: [PATCH 3/3] Feedback from Johannes --- .../domain/settings/IrisChatSubSettings.java | 1 - .../dto/IrisCombinedChatSubSettingsDTO.java | 6 +- ...ombinedTextExerciseChatSubSettingsDTO.java | 6 +- .../service/settings/IrisSettingsService.java | 60 ++++++++----------- .../settings/IrisSubSettingsService.java | 4 +- .../ProgrammingExerciseRepository.java | 6 +- .../web/ProgrammingExerciseResource.java | 3 +- 7 files changed, 37 insertions(+), 49 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java index 295d6a0042d0..e8773c783914 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/domain/settings/IrisChatSubSettings.java @@ -28,7 +28,6 @@ public class IrisChatSubSettings extends IrisSubSettings { @Column(name = "rate_limit_timeframe_hours") private Integer rateLimitTimeframeHours; - @Nullable @Column(name = "enabled_for_categories") @Convert(converter = IrisListConverter.class) private SortedSet enabledForCategories = new TreeSet<>(); diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java index 9ca2784daf5d..4f003471a4d7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedChatSubSettingsDTO.java @@ -1,13 +1,13 @@ package de.tum.cit.aet.artemis.iris.dto; -import java.util.Set; +import java.util.SortedSet; import jakarta.annotation.Nullable; import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record IrisCombinedChatSubSettingsDTO(boolean enabled, Integer rateLimit, Integer rateLimitTimeframeHours, @Nullable Set allowedVariants, - @Nullable String selectedVariant, @Nullable Set enabledForCategories) { +public record IrisCombinedChatSubSettingsDTO(boolean enabled, Integer rateLimit, Integer rateLimitTimeframeHours, @Nullable SortedSet allowedVariants, + @Nullable String selectedVariant, @Nullable SortedSet enabledForCategories) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedTextExerciseChatSubSettingsDTO.java b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedTextExerciseChatSubSettingsDTO.java index 2db220d79a44..f8a5ccb61748 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedTextExerciseChatSubSettingsDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/dto/IrisCombinedTextExerciseChatSubSettingsDTO.java @@ -1,13 +1,13 @@ package de.tum.cit.aet.artemis.iris.dto; -import java.util.Set; +import java.util.SortedSet; import jakarta.annotation.Nullable; import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record IrisCombinedTextExerciseChatSubSettingsDTO(boolean enabled, Integer rateLimit, Integer rateLimitTimeframeHours, @Nullable Set allowedVariants, - @Nullable String selectedVariant, @Nullable Set enabledForCategories) { +public record IrisCombinedTextExerciseChatSubSettingsDTO(boolean enabled, Integer rateLimit, Integer rateLimitTimeframeHours, @Nullable SortedSet allowedVariants, + @Nullable String selectedVariant, @Nullable SortedSet enabledForCategories) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java index 03993567e3b8..6047631fb5bf 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSettingsService.java @@ -303,7 +303,7 @@ private IrisCourseSettings updateCourseSettings(IrisCourseSettings existingSetti // Automatically update the exercise settings when the enabledForCategories is changed var newEnabledForCategoriesExerciseChat = existingSettings.getIrisChatSettings() == null ? new TreeSet() : existingSettings.getIrisChatSettings().getEnabledForCategories(); - if (!Objects.equals(oldEnabledForCategoriesExerciseChat, newEnabledForCategoriesExerciseChat)) { + if (!oldEnabledForCategoriesExerciseChat.equals(newEnabledForCategoriesExerciseChat)) { programmingExerciseRepository.findAllWithCategoriesByCourseId(existingSettings.getCourse().getId()) .forEach(exercise -> setEnabledForExerciseByCategories(exercise, oldEnabledForCategoriesExerciseChat, newEnabledForCategoriesExerciseChat)); } @@ -329,29 +329,15 @@ private IrisCourseSettings updateCourseSettings(IrisCourseSettings existingSetti * @param newEnabledForCategories The new enabled categories */ public void setEnabledForExerciseByCategories(Exercise exercise, SortedSet oldEnabledForCategories, SortedSet newEnabledForCategories) { - var removedCategories = new TreeSet<>(oldEnabledForCategories == null ? Set.of() : oldEnabledForCategories); + var removedCategories = new TreeSet<>(oldEnabledForCategories); removedCategories.removeAll(newEnabledForCategories); var categories = getCategoryNames(exercise.getCategories()); - if (newEnabledForCategories != null && categories.stream().anyMatch(newEnabledForCategories::contains)) { - var exerciseSettings = getRawIrisSettingsFor(exercise); - if (exercise instanceof ProgrammingExercise) { - exerciseSettings.getIrisChatSettings().setEnabled(true); - } - else if (exercise instanceof TextExercise) { - exerciseSettings.getIrisTextExerciseChatSettings().setEnabled(true); - } - irisSettingsRepository.save(exerciseSettings); + if (categories.stream().anyMatch(newEnabledForCategories::contains)) { + setExerciseSettingsEnabled(exercise, true); } else if (categories.stream().anyMatch(removedCategories::contains)) { - var exerciseSettings = getRawIrisSettingsFor(exercise); - if (exercise instanceof ProgrammingExercise) { - exerciseSettings.getIrisChatSettings().setEnabled(false); - } - else if (exercise instanceof TextExercise) { - exerciseSettings.getIrisTextExerciseChatSettings().setEnabled(false); - } - irisSettingsRepository.save(exerciseSettings); + setExerciseSettingsEnabled(exercise, false); } } @@ -388,25 +374,29 @@ else if (exercise instanceof TextExercise) { } if (newCategories.stream().anyMatch(enabledForCategories::contains)) { - var exerciseSettings = getRawIrisSettingsFor(exercise); - if (exercise instanceof ProgrammingExercise) { - exerciseSettings.getIrisChatSettings().setEnabled(true); - } - else if (exercise instanceof TextExercise) { - exerciseSettings.getIrisTextExerciseChatSettings().setEnabled(true); - } - irisSettingsRepository.save(exerciseSettings); + setExerciseSettingsEnabled(exercise, true); } else if (oldCategories.stream().anyMatch(enabledForCategories::contains)) { - var exerciseSettings = getRawIrisSettingsFor(exercise); - if (exercise instanceof ProgrammingExercise) { - exerciseSettings.getIrisChatSettings().setEnabled(false); - } - else if (exercise instanceof TextExercise) { - exerciseSettings.getIrisTextExerciseChatSettings().setEnabled(false); - } - irisSettingsRepository.save(exerciseSettings); + setExerciseSettingsEnabled(exercise, false); + } + } + + /** + * Helper method to set the enabled status for an exercise's Iris settings. + * Currently able to handle {@link ProgrammingExercise} and {@link TextExercise} settings. + * + * @param exercise The exercise to update the enabled status for + * @param enabled Whether the Iris settings should be enabled + */ + private void setExerciseSettingsEnabled(Exercise exercise, boolean enabled) { + var exerciseSettings = getRawIrisSettingsFor(exercise); + if (exercise instanceof ProgrammingExercise) { + exerciseSettings.getIrisChatSettings().setEnabled(enabled); + } + else if (exercise instanceof TextExercise) { + exerciseSettings.getIrisTextExerciseChatSettings().setEnabled(enabled); } + irisSettingsRepository.save(exerciseSettings); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java index 0cf5af873aad..2c284b6ea1f8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java +++ b/src/main/java/de/tum/cit/aet/artemis/iris/service/settings/IrisSubSettingsService.java @@ -332,7 +332,7 @@ private Integer getCombinedRateLimit(List settingsList) { * @param subSettingsFunction Function to get the sub settings from an IrisSettings object. * @return Combined allowedVariants field. */ - private Set getCombinedAllowedVariants(List settingsList, Function subSettingsFunction) { + private SortedSet getCombinedAllowedVariants(List settingsList, Function subSettingsFunction) { return settingsList.stream().filter(Objects::nonNull).map(subSettingsFunction).filter(Objects::nonNull).map(IrisSubSettings::getAllowedVariants).filter(Objects::nonNull) .filter(models -> !models.isEmpty()).reduce((first, second) -> second).orElse(new TreeSet<>()); } @@ -350,7 +350,7 @@ private String getCombinedSelectedVariant(List settingsList, Funct .filter(model -> model != null && !model.isBlank()).reduce((first, second) -> second).orElse(null); } - private Set getCombinedEnabledForCategories(List settingsList, Function subSettingsFunction) { + private SortedSet getCombinedEnabledForCategories(List settingsList, Function subSettingsFunction) { return settingsList.stream().filter(Objects::nonNull).filter(settings -> settings instanceof IrisCourseSettings).map(subSettingsFunction).filter(Objects::nonNull) .map(IrisChatSubSettings::getEnabledForCategories).filter(Objects::nonNull).filter(models -> !models.isEmpty()).reduce((first, second) -> second) .orElse(new TreeSet<>()); 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 d4cde4abb323..1185299bef6b 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 @@ -109,7 +109,7 @@ Optional findWithTemplateAndSolutionParticipationTeamAssign Optional findWithAuxiliaryRepositoriesById(long exerciseId); @EntityGraph(type = LOAD, attributePaths = { "auxiliaryRepositories", "competencies", "buildConfig", "categories" }) - Optional findWithAuxiliaryRepositoriesCompetenciesAndBuildConfigById(long exerciseId); + Optional findForUpdateById(long exerciseId); @EntityGraph(type = LOAD, attributePaths = "submissionPolicy") Optional findWithSubmissionPolicyById(long exerciseId); @@ -599,8 +599,8 @@ default ProgrammingExercise findByIdWithAuxiliaryRepositoriesElseThrow(long prog * @return The programming exercise related to the given id */ @NotNull - default ProgrammingExercise findByIdWithAuxiliaryRepositoriesCompetenciesAndBuildConfigElseThrow(long programmingExerciseId) throws EntityNotFoundException { - return getValueElseThrow(findWithAuxiliaryRepositoriesCompetenciesAndBuildConfigById(programmingExerciseId), programmingExerciseId); + default ProgrammingExercise findForUpdateByIdElseThrow(long programmingExerciseId) throws EntityNotFoundException { + return getValueElseThrow(findForUpdateById(programmingExerciseId), programmingExerciseId); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java index 2225baa81759..189165f07cac 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java @@ -298,8 +298,7 @@ public ResponseEntity updateProgrammingExercise(@RequestBod checkProgrammingExerciseForError(updatedProgrammingExercise); - var programmingExerciseBeforeUpdate = programmingExerciseRepository - .findByIdWithAuxiliaryRepositoriesCompetenciesAndBuildConfigElseThrow(updatedProgrammingExercise.getId()); + var programmingExerciseBeforeUpdate = programmingExerciseRepository.findForUpdateByIdElseThrow(updatedProgrammingExercise.getId()); if (!Objects.equals(programmingExerciseBeforeUpdate.getShortName(), updatedProgrammingExercise.getShortName())) { throw new BadRequestAlertException("The programming exercise short name cannot be changed", ENTITY_NAME, "shortNameCannotChange"); }