Skip to content

Commit

Permalink
Programming exercises: Add feedback discussion feature to feedback an…
Browse files Browse the repository at this point in the history
…alysis table (#9810)
  • Loading branch information
az108 authored Nov 26, 2024
1 parent 17eb6ce commit 4eb3fe9
Show file tree
Hide file tree
Showing 24 changed files with 925 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -692,4 +692,15 @@ public void deleteLongFeedback(List<Feedback> feedbackList, Result result) {
List<Feedback> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -373,4 +373,18 @@ public ResponseEntity<Page<FeedbackAffectedStudentDTO>> getAffectedStudentsWithF

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<Long> countAffectedStudentsByFeedbackDetailText(@PathVariable long exerciseId, @RequestParam("detailText") String detailText) {
long affectedStudentCount = resultService.getAffectedStudentCountByFeedbackDetailText(exerciseId, detailText);
return ResponseEntity.ok(affectedStudentCount);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package de.tum.cit.aet.artemis.communication.dto;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record FeedbackChannelRequestDTO(ChannelDTO channel, String feedbackDetailText) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,21 @@
import org.springframework.util.StringUtils;

import de.tum.cit.aet.artemis.communication.domain.ConversationParticipant;
import de.tum.cit.aet.artemis.communication.domain.NotificationType;
import de.tum.cit.aet.artemis.communication.domain.conversation.Channel;
import de.tum.cit.aet.artemis.communication.dto.ChannelDTO;
import de.tum.cit.aet.artemis.communication.dto.MetisCrudAction;
import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository;
import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository;
import de.tum.cit.aet.artemis.communication.service.conversation.errors.ChannelNameDuplicateException;
import de.tum.cit.aet.artemis.communication.service.notifications.SingleUserNotificationService;
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.BadRequestAlertException;
import de.tum.cit.aet.artemis.core.repository.UserRepository;
import de.tum.cit.aet.artemis.exam.domain.Exam;
import de.tum.cit.aet.artemis.exercise.domain.Exercise;
import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository;
import de.tum.cit.aet.artemis.lecture.domain.Lecture;

@Profile(PROFILE_CORE)
Expand All @@ -47,12 +50,18 @@ public class ChannelService {

private final UserRepository userRepository;

private final StudentParticipationRepository studentParticipationRepository;

private final SingleUserNotificationService singleUserNotificationService;

public ChannelService(ConversationParticipantRepository conversationParticipantRepository, ChannelRepository channelRepository, ConversationService conversationService,
UserRepository userRepository) {
UserRepository userRepository, StudentParticipationRepository studentParticipationRepository, SingleUserNotificationService singleUserNotificationService) {
this.conversationParticipantRepository = conversationParticipantRepository;
this.channelRepository = channelRepository;
this.conversationService = conversationService;
this.userRepository = userRepository;
this.studentParticipationRepository = studentParticipationRepository;
this.singleUserNotificationService = singleUserNotificationService;
}

/**
Expand Down Expand Up @@ -405,4 +414,40 @@ private static String generateChannelNameFromTitle(@NotNull String prefix, Optio
}
return channelName;
}

/**
* 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.
* @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) {
Channel channelToCreate = new Channel();
channelToCreate.setName(channelDTO.getName());
channelToCreate.setIsPublic(channelDTO.getIsPublic());
channelToCreate.setIsAnnouncementChannel(channelDTO.getIsAnnouncementChannel());
channelToCreate.setIsArchived(false);
channelToCreate.setDescription(channelDTO.getDescription());

if (channelToCreate.getName() != null && channelToCreate.getName().trim().startsWith("$")) {
throw new BadRequestAlertException("User generated channels cannot start with $", "channel", "channelNameInvalid");
}

Channel createdChannel = createChannel(course, channelToCreate, Optional.of(requestingUser));

List<String> userLogins = studentParticipationRepository.findAffectedLoginsByFeedbackDetailText(exerciseId, feedbackDetailText);

if (userLogins != null && !userLogins.isEmpty()) {
var registeredUsers = registerUsersToChannel(false, false, false, userLogins, course, createdChannel);
registeredUsers.forEach(user -> singleUserNotificationService.notifyClientAboutConversationCreationOrDeletion(createdChannel, user, requestingUser,
NotificationType.CONVERSATION_ADD_USER_CHANNEL));
}

return createdChannel;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@
import de.tum.cit.aet.artemis.communication.domain.conversation.Channel;
import de.tum.cit.aet.artemis.communication.dto.ChannelDTO;
import de.tum.cit.aet.artemis.communication.dto.ChannelIdAndNameDTO;
import de.tum.cit.aet.artemis.communication.dto.FeedbackChannelRequestDTO;
import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository;
import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository;
import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService;
import de.tum.cit.aet.artemis.communication.service.conversation.ConversationDTOService;
import de.tum.cit.aet.artemis.communication.service.conversation.ConversationService;
import de.tum.cit.aet.artemis.communication.service.conversation.auth.ChannelAuthorizationService;
import de.tum.cit.aet.artemis.communication.service.notifications.SingleUserNotificationService;
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;
import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException;
Expand All @@ -50,7 +52,9 @@
import de.tum.cit.aet.artemis.core.repository.UserRepository;
import de.tum.cit.aet.artemis.core.security.Role;
import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent;
import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastEditorInCourse;
import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService;
import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository;
import de.tum.cit.aet.artemis.tutorialgroup.service.TutorialGroupChannelManagementService;

@Profile(PROFILE_CORE)
Expand Down Expand Up @@ -80,10 +84,13 @@ public class ChannelResource extends ConversationManagementResource {

private final ConversationParticipantRepository conversationParticipantRepository;

private final StudentParticipationRepository studentParticipationRepository;

public ChannelResource(ConversationParticipantRepository conversationParticipantRepository, SingleUserNotificationService singleUserNotificationService,
ChannelService channelService, ChannelRepository channelRepository, ChannelAuthorizationService channelAuthorizationService,
AuthorizationCheckService authorizationCheckService, ConversationDTOService conversationDTOService, CourseRepository courseRepository, UserRepository userRepository,
ConversationService conversationService, TutorialGroupChannelManagementService tutorialGroupChannelManagementService) {
ConversationService conversationService, TutorialGroupChannelManagementService tutorialGroupChannelManagementService,
StudentParticipationRepository studentParticipationRepository) {
super(courseRepository);
this.channelService = channelService;
this.channelRepository = channelRepository;
Expand All @@ -95,6 +102,7 @@ public ChannelResource(ConversationParticipantRepository conversationParticipant
this.tutorialGroupChannelManagementService = tutorialGroupChannelManagementService;
this.singleUserNotificationService = singleUserNotificationService;
this.conversationParticipantRepository = conversationParticipantRepository;
this.studentParticipationRepository = studentParticipationRepository;
}

/**
Expand Down Expand Up @@ -460,6 +468,34 @@ public ResponseEntity<Void> deregisterUsers(@PathVariable Long courseId, @PathVa
return ResponseEntity.ok().build();
}

/**
* POST /api/courses/:courseId/channels/: Creates a new feedback-specific channel in a course.
*
* @param courseId where the channel is being created.
* @param exerciseId for which the feedback channel is being created.
* @param feedbackChannelRequest containing a DTO with the properties of the channel (e.g., name, description, visibility)
* and the feedback detail text used to determine the affected students to be added to the channel.
* @return ResponseEntity with status 201 (Created) and the body containing the details of the created channel.
* @throws URISyntaxException if the URI for the created resource cannot be constructed.
* @throws BadRequestAlertException if the channel name starts with an invalid prefix (e.g., "$").
*/
@PostMapping("{courseId}/{exerciseId}/feedback-channel")
@EnforceAtLeastEditorInCourse
public ResponseEntity<ChannelDTO> createFeedbackChannel(@PathVariable Long courseId, @PathVariable Long exerciseId,
@RequestBody FeedbackChannelRequestDTO feedbackChannelRequest) throws URISyntaxException {
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();

User requestingUser = userRepository.getUserWithGroupsAndAuthorities();
Course course = courseRepository.findByIdElseThrow(courseId);
checkCommunicationEnabledElseThrow(course);
Channel createdChannel = channelService.createFeedbackChannel(course, exerciseId, channelDTO, feedbackDetailText, requestingUser);

return ResponseEntity.created(new URI("/api/channels/" + createdChannel.getId())).body(conversationDTOService.convertChannelToDTO(requestingUser, createdChannel));
}

private void checkEntityIdMatchesPathIds(Channel channel, Optional<Long> courseId, Optional<Long> conversationId) {
courseId.ifPresent(courseIdValue -> {
if (!channel.getCourse().getId().equals(courseIdValue)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1387,4 +1387,54 @@ SELECT MAX(pr.id)
ORDER BY p.student.firstName ASC
""")
Page<FeedbackAffectedStudentDTO> findAffectedStudentsByFeedbackId(@Param("exerciseId") long exerciseId, @Param("feedbackIds") List<Long> feedbackIds, Pageable pageable);

/**
* 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
FROM ProgrammingExerciseStudentParticipation p
INNER JOIN p.submissions s
INNER JOIN s.results r ON r.id = (
SELECT MAX(pr.id)
FROM s.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
""")
List<String> findAffectedLoginsByFeedbackDetailText(@Param("exerciseId") long exerciseId, @Param("detailText") String detailText);

/**
* Counts the number of distinct students affected by a specific feedback detail text for a given programming exercise.
* <p>
* 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.
* </p>
*
* @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.
*/
@Query("""
SELECT COUNT(DISTINCT p.student.id)
FROM ProgrammingExerciseStudentParticipation p
INNER JOIN p.submissions s
INNER JOIN s.results r ON r.id = (
SELECT MAX(pr.id)
FROM s.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
""")
long countAffectedStudentsByFeedbackDetailText(@Param("exerciseId") long exerciseId, @Param("detailText") String detailText);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<div class="modal-header">
<h4 class="modal-title" [jhiTranslate]="TRANSLATION_BASE + '.header'"></h4>
<button type="button" class="btn-close" aria-label="Close" (click)="dismiss()"></button>
</div>
<div class="modal-body">
<p [jhiTranslate]="TRANSLATION_BASE + '.confirmationMessage'" [translateValues]="{ count: affectedStudentsCount }"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="dismiss()" [jhiTranslate]="TRANSLATION_BASE + '.cancel'"></button>
<button type="button" class="btn btn-primary" (click)="confirm()" [jhiTranslate]="TRANSLATION_BASE + '.confirm'"></button>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Component, inject, input } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module';

@Component({
selector: 'jhi-confirm-feedback-channel-creation-modal',
templateUrl: './confirm-feedback-channel-creation-modal.component.html',
imports: [ArtemisSharedCommonModule],
standalone: true,
})
export class ConfirmFeedbackChannelCreationModalComponent {
protected readonly TRANSLATION_BASE = 'artemisApp.programmingExercise.configureGrading.feedbackAnalysis.feedbackDetailChannel.confirmationModal';
affectedStudentsCount = input.required<number>();
private activeModal = inject(NgbActiveModal);

confirm(): void {
this.activeModal.close(true);
}

dismiss(): void {
this.activeModal.dismiss();
}
}
Loading

0 comments on commit 4eb3fe9

Please sign in to comment.