diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAffectedStudentDTO.java b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAffectedStudentDTO.java index 71c6b73a208f..3919ec9cd858 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAffectedStudentDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAffectedStudentDTO.java @@ -1,4 +1,4 @@ package de.tum.cit.aet.artemis.assessment.dto; -public record FeedbackAffectedStudentDTO(long courseId, long participationId, String firstName, String lastName, String login, String repositoryURI) { +public record FeedbackAffectedStudentDTO(long participationId, String firstName, String lastName, String login, String repositoryURI) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java index e56722f079cf..c93578cd10c5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackAnalysisResponseDTO.java @@ -9,5 +9,5 @@ @JsonInclude(JsonInclude.Include.NON_EMPTY) public record FeedbackAnalysisResponseDTO(SearchResultPageDTO feedbackDetails, long totalItems, Set taskNames, List testCaseNames, - List errorCategories) { + List errorCategories, long highestOccurrenceOfGroupedFeedback) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java index d22a036e7489..0fee28e9672c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/dto/FeedbackDetailDTO.java @@ -6,11 +6,11 @@ import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record FeedbackDetailDTO(List concatenatedFeedbackIds, long count, double relativeCount, String detailText, String testCaseName, String taskName, - String errorCategory) { +public record FeedbackDetailDTO(List feedbackIds, long count, double relativeCount, List detailTexts, String testCaseName, String taskName, String errorCategory) { - public FeedbackDetailDTO(String concatenatedFeedbackIds, long count, double relativeCount, String detailText, String testCaseName, String taskName, String errorCategory) { - this(Arrays.stream(concatenatedFeedbackIds.split(",")).map(Long::valueOf).toList(), count, relativeCount, detailText, testCaseName, taskName, errorCategory); + public FeedbackDetailDTO(String feedbackId, long count, double relativeCount, String detailText, String testCaseName, String taskName, String errorCategory) { + // Feedback IDs are gathered in the query using a comma separator, and the detail texts are stored in a list because, in case aggregation is applied, the detail texts are + // grouped together + this(Arrays.stream(feedbackId.split(",")).map(Long::valueOf).toList(), count, relativeCount, List.of(detailText), testCaseName, taskName, errorCategory); } - } diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java index 1f62ef78665b..1dce0090c001 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java @@ -4,7 +4,6 @@ import java.time.ZonedDateTime; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import java.util.Comparator; import java.util.HashMap; @@ -25,7 +24,7 @@ import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.assessment.domain.AssessmentType; @@ -49,7 +48,7 @@ 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.dto.SearchResultPageDTO; -import de.tum.cit.aet.artemis.core.dto.pageablesearch.PageableSearchDTO; +import de.tum.cit.aet.artemis.core.dto.SortingOrder; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.Role; @@ -64,6 +63,7 @@ import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository; import de.tum.cit.aet.artemis.exercise.service.ExerciseDateService; import de.tum.cit.aet.artemis.lti.service.LtiNewResultService; +import de.tum.cit.aet.artemis.modeling.service.compass.strategy.NameSimilarity; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation; @@ -126,6 +126,10 @@ public class ResultService { private final ProgrammingExerciseRepository programmingExerciseRepository; + private static final int MAX_FEEDBACK_IDS = 5; + + private static final double SIMILARITY_THRESHOLD = 0.9; + public ResultService(UserRepository userRepository, ResultRepository resultRepository, Optional ltiNewResultService, ResultWebsocketService resultWebsocketService, ComplaintResponseRepository complaintResponseRepository, RatingRepository ratingRepository, FeedbackRepository feedbackRepository, LongFeedbackTextRepository longFeedbackTextRepository, ComplaintRepository complaintRepository, @@ -570,10 +574,12 @@ private Result shouldSaveResult(@NotNull Result result, boolean shouldSave) { * Pagination and sorting: * - Sorting is applied based on the specified column and order (ascending or descending). * - The result is paginated according to the provided page number and page size. + * Additionally one can group the feedback detail text. * - * @param exerciseId The ID of the exercise for which feedback details should be retrieved. - * @param data The {@link FeedbackPageableDTO} containing page number, page size, search term, sorting options, and filtering parameters - * (task names, test cases, occurrence range, error categories). + * @param exerciseId The ID of the exercise for which feedback details should be retrieved. + * @param data The {@link FeedbackPageableDTO} containing page number, page size, search term, sorting options, and filtering parameters + * (task names, test cases, occurrence range, error categories). + * @param groupFeedback The flag to enable grouping and aggregation of feedback details. * @return A {@link FeedbackAnalysisResponseDTO} object containing: * - A {@link SearchResultPageDTO} of paginated feedback details. * - The total number of distinct results for the exercise. @@ -581,7 +587,7 @@ private Result shouldSaveResult(@NotNull Result result, boolean shouldSave) { * - A list of active test case names used in the feedback. * - A list of predefined error categories ("Student Error," "Ares Error," "AST Error") available for filtering. */ - public FeedbackAnalysisResponseDTO getFeedbackDetailsOnPage(long exerciseId, FeedbackPageableDTO data) { + public FeedbackAnalysisResponseDTO getFeedbackDetailsOnPage(long exerciseId, FeedbackPageableDTO data, boolean groupFeedback) { // 1. Fetch programming exercise with associated test cases ProgrammingExercise programmingExercise = programmingExerciseRepository.findWithTestCasesByIdElseThrow(exerciseId); @@ -598,12 +604,12 @@ public FeedbackAnalysisResponseDTO getFeedbackDetailsOnPage(long exerciseId, Fee Set taskNames = tasks.stream().map(ProgrammingExerciseTask::getTaskName).collect(Collectors.toSet()); // 5. Include unassigned tasks if specified by the filter; otherwise, only include specified tasks - List includeUnassignedTasks = new ArrayList<>(taskNames); + List includeNotAssignedToTask = new ArrayList<>(taskNames); if (!data.getFilterTasks().isEmpty()) { - includeUnassignedTasks.removeAll(data.getFilterTasks()); + includeNotAssignedToTask.removeAll(data.getFilterTasks()); } else { - includeUnassignedTasks.clear(); + includeNotAssignedToTask.clear(); } // 6. Define the occurrence range based on filter parameters @@ -614,22 +620,113 @@ public FeedbackAnalysisResponseDTO getFeedbackDetailsOnPage(long exerciseId, Fee List filterErrorCategories = data.getFilterErrorCategories(); // 8. Set up pagination and sorting based on input data - final var pageable = PageUtil.createDefaultPageRequest(data, PageUtil.ColumnMapping.FEEDBACK_ANALYSIS); + final Pageable pageable = groupFeedback ? Pageable.unpaged() : PageUtil.createDefaultPageRequest(data, PageUtil.ColumnMapping.FEEDBACK_ANALYSIS); - // 9. Query the database to retrieve paginated and filtered feedback + // 9. Query the database based on groupFeedback attribute to retrieve paginated and filtered feedback final Page feedbackDetailPage = studentParticipationRepository.findFilteredFeedbackByExerciseId(exerciseId, - StringUtils.isBlank(data.getSearchTerm()) ? "" : data.getSearchTerm().toLowerCase(), data.getFilterTestCases(), includeUnassignedTasks, minOccurrence, + StringUtils.isBlank(data.getSearchTerm()) ? "" : data.getSearchTerm().toLowerCase(), data.getFilterTestCases(), includeNotAssignedToTask, minOccurrence, maxOccurrence, filterErrorCategories, pageable); + ; + List processedDetails; + int totalPages = 0; + long totalCount = 0; + long highestOccurrenceOfGroupedFeedback = 0; + if (!groupFeedback) { + // Process and map feedback details, calculating relative count and assigning task names + processedDetails = feedbackDetailPage.getContent().stream() + .map(detail -> new FeedbackDetailDTO(detail.feedbackIds().subList(0, Math.min(detail.feedbackIds().size(), MAX_FEEDBACK_IDS)), detail.count(), + (detail.count() * 100.00) / distinctResultCount, detail.detailTexts(), detail.testCaseName(), detail.taskName(), detail.errorCategory())) + .toList(); + totalPages = feedbackDetailPage.getTotalPages(); + totalCount = feedbackDetailPage.getTotalElements(); + } + else { + // Fetch all feedback details + List allFeedbackDetails = feedbackDetailPage.getContent(); + + // Apply grouping and aggregation with a similarity threshold of 90% + List aggregatedFeedbackDetails = aggregateFeedback(allFeedbackDetails, SIMILARITY_THRESHOLD); + + highestOccurrenceOfGroupedFeedback = aggregatedFeedbackDetails.stream().mapToLong(FeedbackDetailDTO::count).max().orElse(0); + // Apply manual sorting + Comparator comparator = getComparatorForFeedbackDetails(data); + List processedDetailsPreSort = new ArrayList<>(aggregatedFeedbackDetails); + processedDetailsPreSort.sort(comparator); + // Apply manual pagination + int page = data.getPage(); + int pageSize = data.getPageSize(); + int start = Math.max(0, (page - 1) * pageSize); + int end = Math.min(start + pageSize, processedDetailsPreSort.size()); + processedDetails = processedDetailsPreSort.subList(start, end); + processedDetails = processedDetails.stream().map(detail -> new FeedbackDetailDTO(detail.feedbackIds().subList(0, Math.min(detail.feedbackIds().size(), 5)), + detail.count(), (detail.count() * 100.00) / distinctResultCount, detail.detailTexts(), detail.testCaseName(), detail.taskName(), detail.errorCategory())) + .toList(); + totalPages = (int) Math.ceil((double) processedDetailsPreSort.size() / pageSize); + totalCount = aggregatedFeedbackDetails.size(); + } - // 10. Process and map feedback details, calculating relative count and assigning task names - List processedDetails = feedbackDetailPage.getContent().stream().map(detail -> new FeedbackDetailDTO(detail.concatenatedFeedbackIds(), detail.count(), - (detail.count() * 100.00) / distinctResultCount, detail.detailText(), detail.testCaseName(), detail.taskName(), detail.errorCategory())).toList(); - // 11. Predefined error categories available for filtering on the client side + // 10. Predefined error categories available for filtering on the client side final List ERROR_CATEGORIES = List.of("Student Error", "Ares Error", "AST Error"); - // 12. Return response containing processed feedback details, task names, active test case names, and error categories - return new FeedbackAnalysisResponseDTO(new SearchResultPageDTO<>(processedDetails, feedbackDetailPage.getTotalPages()), feedbackDetailPage.getTotalElements(), taskNames, - activeTestCaseNames, ERROR_CATEGORIES); + // 11. Return response containing processed feedback details, task names, active test case names, and error categories + return new FeedbackAnalysisResponseDTO(new SearchResultPageDTO<>(processedDetails, totalPages), totalCount, taskNames, activeTestCaseNames, ERROR_CATEGORIES, + highestOccurrenceOfGroupedFeedback); + } + + private Comparator getComparatorForFeedbackDetails(FeedbackPageableDTO search) { + Map> comparators = Map.of("count", Comparator.comparingLong(FeedbackDetailDTO::count), "detailTexts", + Comparator.comparing(detail -> detail.detailTexts().isEmpty() ? "" : detail.detailTexts().getFirst(), // Sort by the first element of the list + String.CASE_INSENSITIVE_ORDER), + "testCaseName", Comparator.comparing(FeedbackDetailDTO::testCaseName, String.CASE_INSENSITIVE_ORDER), "taskName", + Comparator.comparing(FeedbackDetailDTO::taskName, String.CASE_INSENSITIVE_ORDER)); + + Comparator comparator = comparators.getOrDefault(search.getSortedColumn(), (a, b) -> 0); + return search.getSortingOrder() == SortingOrder.ASCENDING ? comparator : comparator.reversed(); + } + + private List aggregateFeedback(List feedbackDetails, double similarityThreshold) { + List processedDetails = new ArrayList<>(); + + for (FeedbackDetailDTO base : feedbackDetails) { + boolean isMerged = false; + + for (FeedbackDetailDTO processed : processedDetails) { + // Ensure feedbacks have the same testCaseName and taskName + if (base.testCaseName().equals(processed.testCaseName()) && base.taskName().equals(processed.taskName())) { + double similarity = NameSimilarity.levenshteinSimilarity(base.detailTexts().getFirst(), processed.detailTexts().getFirst()); + + if (similarity > similarityThreshold) { + // Merge the current base feedback into the processed feedback + List mergedFeedbackIds = new ArrayList<>(processed.feedbackIds()); + if (processed.feedbackIds().size() < MAX_FEEDBACK_IDS) { + mergedFeedbackIds.addAll(base.feedbackIds()); + } + + List mergedTexts = new ArrayList<>(processed.detailTexts()); + mergedTexts.add(base.detailTexts().getFirst()); + + long mergedCount = processed.count() + base.count(); + + // Replace the processed entry with the updated one + processedDetails.remove(processed); + FeedbackDetailDTO updatedProcessed = new FeedbackDetailDTO(mergedFeedbackIds, mergedCount, 0, mergedTexts, processed.testCaseName(), processed.taskName(), + processed.errorCategory()); + processedDetails.add(updatedProcessed); // Add the updated entry + isMerged = true; + break; // No need to check further + } + } + } + + if (!isMerged) { + // If not merged, add it as a new entry in processedDetails + FeedbackDetailDTO newEntry = new FeedbackDetailDTO(base.feedbackIds(), base.count(), 0, List.of(base.detailTexts().getFirst()), base.testCaseName(), + base.taskName(), base.errorCategory()); + processedDetails.add(newEntry); + } + } + + return processedDetails; } /** @@ -648,20 +745,15 @@ public long getMaxCountForExercise(long exerciseId) { /** * Retrieves a paginated list of students affected by specific feedback entries for a given exercise. *
- * This method filters students based on feedback IDs and returns participation details for each affected student. It uses - * pagination and sorting (order based on the {@link PageUtil.ColumnMapping#AFFECTED_STUDENTS}) to allow efficient retrieval and sorting of the results, thus supporting large - * datasets. + * This method filters students based on feedback IDs and returns participation details for each affected student. *
* * @param exerciseId for which the affected student participation data is requested. * @param feedbackIds used to filter the participation to only those affected by specific feedback entries. - * @param data A {@link PageableSearchDTO} object containing pagination and sorting parameters. - * @return A {@link Page} of {@link FeedbackAffectedStudentDTO} objects, each representing a student affected by the feedback. + * @return A {@link List} of {@link FeedbackAffectedStudentDTO} objects, each representing a student affected by the feedback. */ - public Page getAffectedStudentsWithFeedbackId(long exerciseId, String feedbackIds, PageableSearchDTO data) { - List feedbackIdLongs = Arrays.stream(feedbackIds.split(",")).map(Long::valueOf).toList(); - PageRequest pageRequest = PageUtil.createDefaultPageRequest(data, PageUtil.ColumnMapping.AFFECTED_STUDENTS); - return studentParticipationRepository.findAffectedStudentsByFeedbackId(exerciseId, feedbackIdLongs, pageRequest); + public List getAffectedStudentsWithFeedbackIds(long exerciseId, List feedbackIds) { + return studentParticipationRepository.findAffectedStudentsByFeedbackIds(exerciseId, feedbackIds); } /** @@ -692,15 +784,4 @@ public void deleteLongFeedback(List feedbackList, Result result) { List feedbacks = new ArrayList<>(feedbackList); result.updateAllFeedbackItems(feedbacks, true); } - - /** - * Retrieves the number of students affected by a specific feedback detail text for a given exercise. - * - * @param exerciseId for which the affected student count is requested. - * @param detailText used to filter affected students. - * @return the total number of distinct students affected by the feedback detail text. - */ - public long getAffectedStudentCountByFeedbackDetailText(long exerciseId, String detailText) { - return studentParticipationRepository.countAffectedStudentsByFeedbackDetailText(exerciseId, detailText); - } } diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java b/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java index ed6bc5ce12d3..a78718f35a39 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/web/ResultResource.java @@ -7,14 +7,15 @@ import java.time.ZonedDateTime; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.stream.Stream; import org.slf4j.Logger; 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.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -23,7 +24,6 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -39,7 +39,6 @@ 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.dto.SearchResultPageDTO; -import de.tum.cit.aet.artemis.core.dto.pageablesearch.PageableSearchDTO; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.repository.UserRepository; import de.tum.cit.aet.artemis.core.security.Role; @@ -297,7 +296,7 @@ public ResponseEntity createResultForExternalSubmission(@PathVariable Lo * Pagination, sorting, and filtering options allow flexible data retrieval: *
    *
  • Pagination: Based on page number and page size, as specified in the request.
  • - *
  • Sorting: By column (e.g., "count" or "detailText") and sorting order (ASCENDING or DESCENDING). + *
  • Sorting: By column (e.g., "count" or "detailTexts") and sorting order (ASCENDING or DESCENDING). * If the specified column is not valid for sorting, the default sorting column is "count".
  • *
  • Filtering: *
      @@ -310,18 +309,19 @@ public ResponseEntity createResultForExternalSubmission(@PathVariable Lo * *
    * - * @param exerciseId The unique identifier of the exercise for which feedback details are requested. - * @param data A {@link FeedbackPageableDTO} object containing pagination, sorting, and filtering parameters, including: - *
      - *
    • Page number and page size
    • - *
    • Search term (optional)
    • - *
    • Sorting order (ASCENDING or DESCENDING)
    • - *
    • Sorted column
    • - *
    • Filter task names (optional)
    • - *
    • Filter test case names (optional)
    • - *
    • Occurrence range (optional)
    • - *
    • Error categories (optional)
    • - *
    + * @param exerciseId The unique identifier of the exercise for which feedback details are requested. + * @param groupFeedback Should the feedback be grouped + * @param data A {@link FeedbackPageableDTO} object containing pagination, sorting, and filtering parameters, including: + *
      + *
    • Page number and page size
    • + *
    • Search term (optional)
    • + *
    • Sorting order (ASCENDING or DESCENDING)
    • + *
    • Sorted column
    • + *
    • Filter task names (optional)
    • + *
    • Filter test case names (optional)
    • + *
    • Occurrence range (optional)
    • + *
    • Error categories (optional)
    • + *
    * @return A {@link ResponseEntity} containing a {@link FeedbackAnalysisResponseDTO}, which includes: *
      *
    • {@link SearchResultPageDTO < FeedbackDetailDTO >} feedbackDetails: Paginated and filtered feedback details for the exercise.
    • @@ -333,8 +333,9 @@ public ResponseEntity createResultForExternalSubmission(@PathVariable Lo */ @GetMapping("exercises/{exerciseId}/feedback-details") @EnforceAtLeastEditorInExercise - public ResponseEntity getFeedbackDetailsPaged(@PathVariable long exerciseId, @ModelAttribute FeedbackPageableDTO data) { - FeedbackAnalysisResponseDTO response = resultService.getFeedbackDetailsOnPage(exerciseId, data); + public ResponseEntity getFeedbackDetailsPaged(@PathVariable long exerciseId, @RequestParam("groupFeedback") boolean groupFeedback, + @ModelAttribute FeedbackPageableDTO data) { + FeedbackAnalysisResponseDTO response = resultService.getFeedbackDetailsOnPage(exerciseId, data, groupFeedback); return ResponseEntity.ok(response); } @@ -359,32 +360,24 @@ public ResponseEntity getMaxCount(@PathVariable long exerciseId) { * and participation details. *
      * - * @param exerciseId for which the participation data is requested. - * @param feedbackIdsHeader to filter affected students by specific feedback entries. - * @param data A {@link PageableSearchDTO} object containing pagination and sorting parameters. - * @return A {@link ResponseEntity} containing a {@link Page} of {@link FeedbackAffectedStudentDTO}, each representing a student affected by the feedback entries. + * @param exerciseId for which the participation data is requested. + * @param feedbackId1 Optional first detail text id to filter affected students by specific feedback entries. + * @param feedbackId2 Optional second detail text id to filter affected students by specific feedback entries. + * @param feedbackId3 Optional third detail text id to filter affected students by specific feedback entries. + * @param feedbackId4 Optional fourth detail text id to filter affected students by specific feedback entries. + * @param feedbackId5 Optional fifth detail text id to filter affected students by specific feedback entries. + * @return A {@link ResponseEntity} containing a {@link List} of {@link FeedbackAffectedStudentDTO}, each representing a student affected by the feedback entries. */ @GetMapping("exercises/{exerciseId}/feedback-details-participation") @EnforceAtLeastEditorInExercise - public ResponseEntity> getAffectedStudentsWithFeedback(@PathVariable long exerciseId, @RequestHeader("feedbackIds") String feedbackIdsHeader, - @ModelAttribute PageableSearchDTO data) { + public ResponseEntity> getAffectedStudentsWithFeedback(@PathVariable long exerciseId, + @RequestParam(value = "feedbackId1", required = false) Long feedbackId1, @RequestParam(value = "feedbackId2", required = false) Long feedbackId2, + @RequestParam(value = "feedbackId3", required = false) Long feedbackId3, @RequestParam(value = "feedbackId4", required = false) Long feedbackId4, + @RequestParam(value = "feedbackId5", required = false) Long feedbackId5) { - Page participation = resultService.getAffectedStudentsWithFeedbackId(exerciseId, feedbackIdsHeader, data); + List feedbackIds = Stream.of(feedbackId1, feedbackId2, feedbackId3, feedbackId4, feedbackId5).filter(Objects::nonNull).toList(); + List participation = resultService.getAffectedStudentsWithFeedbackIds(exerciseId, feedbackIds); return ResponseEntity.ok(participation); } - - /** - * GET /exercises/{exerciseId}/feedback-detail/affected-students : Retrieves the count of students affected by a specific feedback detail text. - * - * @param exerciseId The ID of the exercise for which affected students are counted. - * @param detailText The feedback detail text to filter by. - * @return A {@link ResponseEntity} containing the count of affected students. - */ - @GetMapping("exercises/{exerciseId}/feedback-detail/affected-students") - @EnforceAtLeastEditorInExercise - public ResponseEntity countAffectedStudentsByFeedbackDetailText(@PathVariable long exerciseId, @RequestParam("detailText") String detailText) { - long affectedStudentCount = resultService.getAffectedStudentCountByFeedbackDetailText(exerciseId, detailText); - return ResponseEntity.ok(affectedStudentCount); - } } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/FeedbackChannelRequestDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/FeedbackChannelRequestDTO.java index d38b1c1d90f2..ca47c30d1be4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/dto/FeedbackChannelRequestDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/FeedbackChannelRequestDTO.java @@ -1,7 +1,9 @@ package de.tum.cit.aet.artemis.communication.dto; +import java.util.List; + import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record FeedbackChannelRequestDTO(ChannelDTO channel, String feedbackDetailText) { +public record FeedbackChannelRequestDTO(ChannelDTO channel, List feedbackDetailTexts, String testCaseName) { } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java index ec61f3a8fe42..d16a17430138 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ChannelService.java @@ -418,15 +418,16 @@ private static String generateChannelNameFromTitle(@NotNull String prefix, Optio /** * Creates a feedback-specific channel for an exercise within a course. * - * @param course in which the channel is being created. - * @param exerciseId of the exercise associated with the feedback channel. - * @param channelDTO containing the properties of the channel to be created, such as name, description, and visibility. - * @param feedbackDetailText used to identify the students affected by the feedback. - * @param requestingUser initiating the channel creation request. + * @param course in which the channel is being created. + * @param exerciseId of the exercise associated with the feedback channel. + * @param channelDTO containing the properties of the channel to be created, such as name, description, and visibility. + * @param feedbackDetailTexts used to identify the students affected by the feedback. + * @param requestingUser initiating the channel creation request. + * @param testCaseName to filter student submissions according to a specific feedback * @return the created {@link Channel} object with its properties. * @throws BadRequestAlertException if the channel name starts with an invalid prefix (e.g., "$"). */ - public Channel createFeedbackChannel(Course course, Long exerciseId, ChannelDTO channelDTO, String feedbackDetailText, User requestingUser) { + public Channel createFeedbackChannel(Course course, Long exerciseId, ChannelDTO channelDTO, List feedbackDetailTexts, String testCaseName, User requestingUser) { Channel channelToCreate = new Channel(); channelToCreate.setName(channelDTO.getName()); channelToCreate.setIsPublic(channelDTO.getIsPublic()); @@ -440,7 +441,7 @@ public Channel createFeedbackChannel(Course course, Long exerciseId, ChannelDTO Channel createdChannel = createChannel(course, channelToCreate, Optional.of(requestingUser)); - List userLogins = studentParticipationRepository.findAffectedLoginsByFeedbackDetailText(exerciseId, feedbackDetailText); + List userLogins = studentParticipationRepository.findAffectedLoginsByFeedbackDetailText(exerciseId, feedbackDetailTexts, testCaseName); if (userLogins != null && !userLogins.isEmpty()) { var registeredUsers = registerUsersToChannel(false, false, false, userLogins, course, createdChannel); diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java index c9d2f0423380..ed1b4e45d478 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/ChannelResource.java @@ -486,12 +486,13 @@ public ResponseEntity createFeedbackChannel(@PathVariable Long cours log.debug("REST request to create feedback channel for course {} and exercise {} with properties: {}", courseId, exerciseId, feedbackChannelRequest); ChannelDTO channelDTO = feedbackChannelRequest.channel(); - String feedbackDetailText = feedbackChannelRequest.feedbackDetailText(); + List feedbackDetailTexts = feedbackChannelRequest.feedbackDetailTexts(); + String testCaseName = feedbackChannelRequest.testCaseName(); User requestingUser = userRepository.getUserWithGroupsAndAuthorities(); Course course = courseRepository.findByIdElseThrow(courseId); checkCommunicationEnabledElseThrow(course); - Channel createdChannel = channelService.createFeedbackChannel(course, exerciseId, channelDTO, feedbackDetailText, requestingUser); + Channel createdChannel = channelService.createFeedbackChannel(course, exerciseId, channelDTO, feedbackDetailTexts, testCaseName, requestingUser); return ResponseEntity.created(new URI("/api/channels/" + createdChannel.getId())).body(conversationDTOService.convertChannelToDTO(requestingUser, createdChannel)); } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java b/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java index 88f7bb7302e6..124430b9386d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/util/PageUtil.java @@ -73,7 +73,7 @@ public enum ColumnMapping { )), FEEDBACK_ANALYSIS(Map.of( "count", "COUNT(f.id)", - "detailText", "f.detailText", + "detailTexts", "f.detailText", "testCaseName", "f.testCase.testName", "taskName", """ COALESCE(( @@ -82,9 +82,6 @@ SELECT MAX(t.taskName) JOIN t.testCases tct WHERE t.exercise.id = :exerciseId AND tct.testName = f.testCase.testName ), '')""" - )), - AFFECTED_STUDENTS(Map.of( - "participationId", "p.id" )); // @formatter:on diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java index d9cb4a6e205a..26deb8669fdc 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java @@ -1277,13 +1277,13 @@ SELECT MAX(t.taskName) ELSE 'Student Error' END ) - FROM StudentParticipation p - LEFT JOIN p.results r ON r.id = ( + FROM ProgrammingExerciseStudentParticipation p + INNER JOIN p.results r ON r.id = ( SELECT MAX(pr.id) FROM p.results pr WHERE pr.participation.id = p.id ) - LEFT JOIN r.feedbacks f + INNER JOIN r.feedbacks f WHERE p.exercise.id = :exerciseId AND p.testRun = FALSE AND f.positive = FALSE @@ -1305,7 +1305,7 @@ HAVING COUNT(f.id) BETWEEN :minOccurrence AND :maxOccurrence """) Page findFilteredFeedbackByExerciseId(@Param("exerciseId") long exerciseId, @Param("searchTerm") String searchTerm, @Param("filterTestCases") List filterTestCases, @Param("filterTaskNames") List filterTaskNames, @Param("minOccurrence") long minOccurrence, - @Param("maxOccurrence") long maxOccurrence, @Param("filterErrorCategories") List filterErrorCategories, Pageable pageable); + @Param("maxOccurrence") long maxOccurrence, @Param("filterErrorCategories") List filterErrorCategories, @Param("pageable") Pageable pageable); /** * Counts the distinct number of latest results for a given exercise, excluding those in practice mode. @@ -1317,8 +1317,8 @@ Page findFilteredFeedbackByExerciseId(@Param("exerciseId") lo */ @Query(""" SELECT COUNT(DISTINCT r.id) - FROM StudentParticipation p - LEFT JOIN p.results r ON r.id = ( + FROM ProgrammingExerciseStudentParticipation p + INNER JOIN p.results r ON r.id = ( SELECT MAX(pr.id) FROM p.results pr WHERE pr.participation.id = p.id @@ -1344,13 +1344,13 @@ SELECT MAX(pr.id) SELECT MAX(feedbackCounts.feedbackCount) FROM ( SELECT COUNT(f.id) AS feedbackCount - FROM StudentParticipation p - LEFT JOIN p.results r ON r.id = ( + FROM ProgrammingExerciseStudentParticipation p + INNER JOIN p.results r ON r.id = ( SELECT MAX(pr.id) FROM p.results pr WHERE pr.participation.id = p.id ) - LEFT JOIN r.feedbacks f + INNER JOIN r.feedbacks f WHERE p.exercise.id = :exerciseId AND p.testRun = FALSE AND f.positive = FALSE @@ -1365,12 +1365,10 @@ SELECT MAX(pr.id) * * @param exerciseId for which the affected student participation data is requested. * @param feedbackIds used to filter the participation to only those affected by specific feedback entries. - * @param pageable A {@link Pageable} object to control pagination and sorting of the results, specifying page number, page size, and sort order. * @return A {@link Page} of {@link FeedbackAffectedStudentDTO} objects, each representing a student affected by the feedback. */ @Query(""" - SELECT new de.tum.cit.aet.artemis.assessment.dto.FeedbackAffectedStudentDTO( - p.exercise.course.id, + SELECT DISTINCT new de.tum.cit.aet.artemis.assessment.dto.FeedbackAffectedStudentDTO( p.id, p.student.firstName, p.student.lastName, @@ -1378,63 +1376,56 @@ SELECT MAX(pr.id) p.repositoryUri ) FROM ProgrammingExerciseStudentParticipation p - LEFT JOIN p.submissions s - LEFT JOIN s.results r - LEFT JOIN r.feedbacks f + INNER JOIN p.results r ON r.id = ( + SELECT MAX(pr.id) + FROM p.results pr + WHERE pr.participation.id = p.id + ) + INNER JOIN r.feedbacks f WHERE p.exercise.id = :exerciseId AND f.id IN :feedbackIds AND p.testRun = FALSE ORDER BY p.student.firstName ASC """) - Page findAffectedStudentsByFeedbackId(@Param("exerciseId") long exerciseId, @Param("feedbackIds") List feedbackIds, Pageable pageable); + List findAffectedStudentsByFeedbackIds(@Param("exerciseId") long exerciseId, @Param("feedbackIds") List feedbackIds); - /** - * Retrieves the logins of students affected by a specific feedback detail text in a given exercise. - * - * @param exerciseId The ID of the exercise for which affected students are requested. - * @param detailText The feedback detail text to filter by. - * @return A list of student logins affected by the given feedback detail text in the specified exercise. - */ @Query(""" - SELECT DISTINCT p.student.login + SELECT f.id FROM ProgrammingExerciseStudentParticipation p - INNER JOIN p.submissions s - INNER JOIN s.results r ON r.id = ( + INNER JOIN p.results r ON r.id = ( SELECT MAX(pr.id) - FROM s.results pr + FROM p.results pr WHERE pr.participation.id = p.id ) INNER JOIN r.feedbacks f WHERE p.exercise.id = :exerciseId - AND f.detailText = :detailText - AND p.testRun = FALSE + AND p.testRun = FALSE + ORDER BY p.student.firstName ASC """) - List findAffectedLoginsByFeedbackDetailText(@Param("exerciseId") long exerciseId, @Param("detailText") String detailText); + List findAffectedStudentsByFeedbackIds2(@Param("exerciseId") long exerciseId); /** - * Counts the number of distinct students affected by a specific feedback detail text for a given programming exercise. - *

      - * This query identifies students whose submissions were impacted by feedback entries matching the provided detail text - * within the specified exercise. Only students with non-test run submissions and negative feedback entries are considered. - *

      + * Retrieves the logins of students affected by a specific feedback detail text in a given exercise. * - * @param exerciseId the ID of the programming exercise for which the count is calculated. - * @param detailText the feedback detail text used to filter the affected students. - * @return the total number of distinct students affected by the feedback detail text. + * @param exerciseId The ID of the exercise for which affected students are requested. + * @param detailTexts The feedback detail text to filter by. + * @param testCaseName The name of the test case for which the feedback is given. + * @return A list of student logins affected by the given feedback detail text in the specified exercise. */ @Query(""" - SELECT COUNT(DISTINCT p.student.id) + SELECT DISTINCT p.student.login FROM ProgrammingExerciseStudentParticipation p - INNER JOIN p.submissions s - INNER JOIN s.results r ON r.id = ( + INNER JOIN p.results r ON r.id = ( SELECT MAX(pr.id) - FROM s.results pr + FROM p.results pr WHERE pr.participation.id = p.id ) INNER JOIN r.feedbacks f WHERE p.exercise.id = :exerciseId - AND f.detailText = :detailText + AND f.detailText IN :detailTexts + AND f.testCase.testName = :testCaseName AND p.testRun = FALSE """) - long countAffectedStudentsByFeedbackDetailText(@Param("exerciseId") long exerciseId, @Param("detailText") String detailText); + List findAffectedLoginsByFeedbackDetailText(@Param("exerciseId") long exerciseId, @Param("detailTexts") List detailTexts, + @Param("testCaseName") String testCaseName); } diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-affected-students-modal.component.html b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-affected-students-modal.component.html index 4f53792a04e5..b17fe5a2dd46 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-affected-students-modal.component.html +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-affected-students-modal.component.html @@ -8,42 +8,40 @@ diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-affected-students-modal.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-affected-students-modal.component.ts index 947f3f3d44cd..ed5b2d2fb36a 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-affected-students-modal.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-affected-students-modal.component.ts @@ -1,10 +1,10 @@ -import { Component, computed, effect, inject, input, signal, untracked } from '@angular/core'; +import { Component, effect, inject, input, signal, untracked } from '@angular/core'; import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { FeedbackAffectedStudentDTO, FeedbackAnalysisService, FeedbackDetail } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { AlertService } from 'app/core/util/alert.service'; -import { PageableResult, PageableSearch, SortingOrder } from 'app/shared/table/pageable-table'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; @Component({ selector: 'jhi-affected-students-modal', @@ -14,14 +14,15 @@ import { PageableResult, PageableSearch, SortingOrder } from 'app/shared/table/p standalone: true, }) export class AffectedStudentsModalComponent { + courseId = input.required(); exerciseId = input.required(); feedbackDetail = input.required(); - readonly participation = signal>({ content: [], totalPages: 0, totalElements: 0 }); + groupFeedback = input.required(); + readonly participation = signal([]); readonly TRANSLATION_BASE = 'artemisApp.programmingExercise.configureGrading.feedbackAnalysis.affectedStudentsModal'; - page = signal(1); - pageSize = signal(10); - readonly collectionsSize = computed(() => this.participation().totalPages * this.pageSize()); + readonly faSpinner = faSpinner; + readonly isLoading = signal(false); activeModal = inject(NgbActiveModal); feedbackService = inject(FeedbackAnalysisService); @@ -37,23 +38,14 @@ export class AffectedStudentsModalComponent { private async loadAffected() { const feedbackDetail = this.feedbackDetail(); - const pageable: PageableSearch = { - page: this.page(), - pageSize: this.pageSize(), - sortedColumn: 'participationId', - sortingOrder: SortingOrder.ASCENDING, - }; - + this.isLoading.set(true); try { - const response = await this.feedbackService.getParticipationForFeedbackIds(this.exerciseId(), feedbackDetail.concatenatedFeedbackIds, pageable); + const response = await this.feedbackService.getParticipationForFeedbackDetailText(this.exerciseId(), feedbackDetail.feedbackIds); this.participation.set(response); } catch (error) { this.alertService.error(this.TRANSLATION_BASE + '.error'); + } finally { + this.isLoading.set(false); } } - - setPage(newPage: number): void { - this.page.set(newPage); - this.loadAffected(); - } } diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.html b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.html index 36d5b08ed5b9..3cfab535a1a9 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.html +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.html @@ -58,9 +58,10 @@