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 50576953916b..1f62ef78665b 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 @@ -692,4 +692,15 @@ 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 431fb66373e8..ed6bc5ce12d3 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 @@ -373,4 +373,18 @@ public ResponseEntity> 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 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 new file mode 100644 index 000000000000..d38b1c1d90f2 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/FeedbackChannelRequestDTO.java @@ -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) { +} 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 c78f44893d8e..791847b75670 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 @@ -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) @@ -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; } /** @@ -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 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; + } } 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 03c7445e02e0..cbb59c4b7e46 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 @@ -35,6 +35,7 @@ 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; @@ -42,6 +43,7 @@ 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; @@ -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) @@ -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; @@ -95,6 +102,7 @@ public ChannelResource(ConversationParticipantRepository conversationParticipant this.tutorialGroupChannelManagementService = tutorialGroupChannelManagementService; this.singleUserNotificationService = singleUserNotificationService; this.conversationParticipantRepository = conversationParticipantRepository; + this.studentParticipationRepository = studentParticipationRepository; } /** @@ -460,6 +468,34 @@ public ResponseEntity 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 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 courseId, Optional conversationId) { courseId.ifPresent(courseIdValue -> { if (!channel.getCourse().getId().equals(courseIdValue)) { 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 2c8e4e02eb7c..d9cb4a6e205a 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 @@ -1387,4 +1387,54 @@ SELECT MAX(pr.id) ORDER BY p.student.firstName ASC """) Page findAffectedStudentsByFeedbackId(@Param("exerciseId") long exerciseId, @Param("feedbackIds") List 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 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. + *

+ * 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. + *

+ * + * @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); } diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component.html b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component.html new file mode 100644 index 000000000000..5e9f73b6f44e --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component.html @@ -0,0 +1,11 @@ + + + diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component.ts new file mode 100644 index 000000000000..4a31192b6299 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component.ts @@ -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(); + private activeModal = inject(NgbActiveModal); + + confirm(): void { + this.activeModal.close(true); + } + + dismiss(): void { + this.activeModal.dismiss(); + } +} 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 new file mode 100644 index 000000000000..36d5b08ed5b9 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.html @@ -0,0 +1,87 @@ + + diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.ts new file mode 100644 index 000000000000..e9b7963f0e78 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.ts @@ -0,0 +1,71 @@ +import { Component, inject, input, output, signal } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ChannelDTO } from 'app/entities/metis/conversation/channel.model'; +import { FeedbackDetail } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; +import { ConfirmFeedbackChannelCreationModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { AlertService } from 'app/core/util/alert.service'; + +@Component({ + selector: 'jhi-feedback-detail-channel-modal', + templateUrl: './feedback-detail-channel-modal.component.html', + imports: [ArtemisSharedCommonModule], + standalone: true, +}) +export class FeedbackDetailChannelModalComponent { + protected readonly TRANSLATION_BASE = 'artemisApp.programmingExercise.configureGrading.feedbackAnalysis.feedbackDetailChannel'; + affectedStudentsCount = input.required(); + feedbackDetail = input.required(); + formSubmitted = output<{ channelDto: ChannelDTO; navigate: boolean }>(); + + isConfirmModalOpen = signal(false); + + private alertService = inject(AlertService); + private readonly formBuilder = inject(FormBuilder); + private readonly activeModal = inject(NgbActiveModal); + private readonly modalService = inject(NgbModal); + form: FormGroup = this.formBuilder.group({ + name: ['', [Validators.required, Validators.maxLength(30), Validators.pattern('^[a-z0-9-]{1}[a-z0-9-]{0,30}$')]], + description: ['', [Validators.required, Validators.maxLength(250)]], + isPublic: [true, Validators.required], + isAnnouncementChannel: [false, Validators.required], + }); + + async submitForm(navigate: boolean): Promise { + if (this.form.valid && !this.isConfirmModalOpen()) { + this.isConfirmModalOpen.set(true); + const result = await this.handleModal(); + if (result) { + const channelDTO = new ChannelDTO(); + channelDTO.name = this.form.get('name')?.value; + channelDTO.description = this.form.get('description')?.value; + channelDTO.isPublic = this.form.get('isPublic')?.value; + channelDTO.isAnnouncementChannel = this.form.get('isAnnouncementChannel')?.value; + + this.formSubmitted.emit({ channelDto: channelDTO, navigate }); + this.closeModal(); + } + this.isConfirmModalOpen.set(false); + } + } + + async handleModal(): Promise { + try { + const modalRef = this.modalService.open(ConfirmFeedbackChannelCreationModalComponent, { centered: true }); + modalRef.componentInstance.affectedStudentsCount = this.affectedStudentsCount; + return await modalRef.result; + } catch (error) { + this.alertService.error(error); + return false; + } + } + + closeModal(): void { + this.activeModal.close(); + } + + dismissModal(): void { + this.activeModal.dismiss(); + } +} diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html index 20206a2c4ae3..2c1d01040253 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html @@ -60,8 +60,11 @@

{{ item.testCaseName }} {{ item.errorCategory }} - + + @if (isCommunicationEnabled()) { + + } } diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts index 9026d7cbb1ec..14da1cbe4cce 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.ts @@ -1,8 +1,9 @@ import { Component, computed, effect, inject, input, signal, untracked } from '@angular/core'; -import { FeedbackAnalysisService, FeedbackDetail } from './feedback-analysis.service'; +import { FeedbackAnalysisService, FeedbackChannelRequestDTO, FeedbackDetail } from './feedback-analysis.service'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { AlertService } from 'app/core/util/alert.service'; -import { faFilter, faSort, faSortDown, faSortUp, faUpRightAndDownLeftFromCenter, faUsers } from '@fortawesome/free-solid-svg-icons'; +import { faFilter, faMessage, faSort, faSortDown, faSortUp, faUsers } from '@fortawesome/free-solid-svg-icons'; +import { facDetails } from '../../../../../../content/icons/icons'; import { SearchResult, SortingOrder } from 'app/shared/table/pageable-table'; import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; import { FeedbackModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-modal.component'; @@ -11,6 +12,9 @@ import { LocalStorageService } from 'ngx-webstorage'; import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; import { SortIconComponent } from 'app/shared/sort/sort-icon.component'; import { AffectedStudentsModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-affected-students-modal.component'; +import { FeedbackDetailChannelModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component'; +import { ChannelDTO } from 'app/entities/metis/conversation/channel.model'; +import { Router } from '@angular/router'; @Component({ selector: 'jhi-feedback-analysis', @@ -23,11 +27,14 @@ import { AffectedStudentsModalComponent } from 'app/exercises/programming/manage export class FeedbackAnalysisComponent { exerciseTitle = input.required(); exerciseId = input.required(); + courseId = input.required(); + isCommunicationEnabled = input.required(); private feedbackAnalysisService = inject(FeedbackAnalysisService); private alertService = inject(AlertService); private modalService = inject(NgbModal); private localStorage = inject(LocalStorageService); + private router = inject(Router); readonly page = signal(1); readonly pageSize = signal(25); @@ -44,8 +51,9 @@ export class FeedbackAnalysisComponent { readonly faSortUp = faSortUp; readonly faSortDown = faSortDown; readonly faFilter = faFilter; - readonly faUpRightAndDownLeftFromCenter = faUpRightAndDownLeftFromCenter; + readonly facDetails = facDetails; readonly faUsers = faUsers; + readonly faMessage = faMessage; readonly SortingOrder = SortingOrder; readonly MAX_FEEDBACK_DETAIL_TEXT_LENGTH = 200; @@ -60,6 +68,8 @@ export class FeedbackAnalysisComponent { readonly maxCount = signal(0); readonly errorCategories = signal([]); + private isFeedbackDetailChannelModalOpen = false; + private readonly debounceLoadData = BaseApiHttpService.debounce(this.loadData.bind(this), 300); constructor() { @@ -117,7 +127,7 @@ export class FeedbackAnalysisComponent { } openFeedbackModal(feedbackDetail: FeedbackDetail): void { - const modalRef = this.modalService.open(FeedbackModalComponent, { centered: true }); + const modalRef = this.modalService.open(FeedbackModalComponent, { centered: true, size: 'lg' }); modalRef.componentInstance.feedbackDetail = signal(feedbackDetail); } @@ -191,4 +201,40 @@ export class FeedbackAnalysisComponent { modalRef.componentInstance.exerciseId = this.exerciseId; modalRef.componentInstance.feedbackDetail = signal(feedbackDetail); } + + async openFeedbackDetailChannelModal(feedbackDetail: FeedbackDetail): Promise { + if (this.isFeedbackDetailChannelModalOpen) { + return; + } + this.isFeedbackDetailChannelModalOpen = true; + const modalRef = this.modalService.open(FeedbackDetailChannelModalComponent, { centered: true, size: 'lg' }); + modalRef.componentInstance.affectedStudentsCount = await this.feedbackAnalysisService.getAffectedStudentCount(this.exerciseId(), feedbackDetail.detailText); + modalRef.componentInstance.feedbackDetail = signal(feedbackDetail); + modalRef.componentInstance.formSubmitted.subscribe(async ({ channelDto, navigate }: { channelDto: ChannelDTO; navigate: boolean }) => { + try { + const feedbackChannelRequest: FeedbackChannelRequestDTO = { + channel: channelDto, + feedbackDetailText: feedbackDetail.detailText, + }; + const createdChannel = await this.feedbackAnalysisService.createChannel(this.courseId(), this.exerciseId(), feedbackChannelRequest); + const channelName = createdChannel.name; + this.alertService.success(this.TRANSLATION_BASE + '.channelSuccess', { channelName }); + if (navigate) { + const urlTree = this.router.createUrlTree(['courses', this.courseId(), 'communication'], { + queryParams: { conversationId: createdChannel.id }, + }); + await this.router.navigateByUrl(urlTree); + } + } catch (error) { + this.alertService.error(error); + } + }); + try { + await modalRef.result; + } catch { + // modal dismissed + } finally { + this.isFeedbackDetailChannelModalOpen = false; + } + } } diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts index 214c9a4e4f4c..d034cc56a506 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service.ts @@ -3,6 +3,7 @@ import { PageableResult, PageableSearch, SearchResult, SearchTermPageableSearch import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; import { HttpHeaders, HttpParams } from '@angular/common/http'; import { FilterData } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-filter-modal.component'; +import { ChannelDTO } from 'app/entities/metis/conversation/channel.model'; export interface FeedbackAnalysisResponse { feedbackDetails: SearchResult; @@ -28,6 +29,10 @@ export interface FeedbackAffectedStudentDTO { login: string; repositoryURI: string; } +export interface FeedbackChannelRequestDTO { + channel: ChannelDTO; + feedbackDetailText: string; +} @Injectable() export class FeedbackAnalysisService extends BaseApiHttpService { search(pageable: SearchTermPageableSearch, options: { exerciseId: number; filters: FilterData }): Promise { @@ -62,4 +67,13 @@ export class FeedbackAnalysisService extends BaseApiHttpService { return this.get>(`exercises/${exerciseId}/feedback-details-participation`, { params, headers }); } + + createChannel(courseId: number, exerciseId: number, feedbackChannelRequest: FeedbackChannelRequestDTO): Promise { + return this.post(`courses/${courseId}/${exerciseId}/feedback-channel`, feedbackChannelRequest); + } + + getAffectedStudentCount(exerciseId: number, feedbackDetailText: string): Promise { + const params = new HttpParams().set('detailText', feedbackDetailText); + return this.get(`exercises/${exerciseId}/feedback-detail/affected-students`, { params }); + } } diff --git a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html index 147c35adea2f..39014c0f5657 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html +++ b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.html @@ -270,8 +270,13 @@

}
- @if (programmingExercise.isAtLeastEditor && activeTab === 'feedback-analysis') { - + @if (programmingExercise.isAtLeastEditor && activeTab === 'feedback-analysis' && programmingExercise.title && programmingExercise.id && course.id) { + }
} diff --git a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts index c5ef675338f5..13ce8237d382 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts @@ -6,7 +6,7 @@ import { TranslateService } from '@ngx-translate/core'; import { AccountService } from 'app/core/auth/account.service'; import { AlertService } from 'app/core/util/alert.service'; import { CourseManagementService } from 'app/course/manage/course-management.service'; -import { Course } from 'app/entities/course.model'; +import { Course, isCommunicationEnabled } from 'app/entities/course.model'; import { IssuesMap, ProgrammingExerciseGradingStatistics } from 'app/entities/programming/programming-exercise-test-case-statistics.model'; import { ProgrammingExerciseTestCase, Visibility } from 'app/entities/programming/programming-exercise-test-case.model'; import { ProgrammingExercise, ProgrammingLanguage } from 'app/entities/programming/programming-exercise.model'; @@ -92,6 +92,7 @@ export class ProgrammingExerciseConfigureGradingComponent implements OnInit, OnD readonly RESET_TABLE = ProgrammingGradingChartsDirective.RESET_TABLE; readonly chartFilterType = ChartFilterType; readonly ProgrammingLanguage = ProgrammingLanguage; + protected readonly isCommunicationEnabled = isCommunicationEnabled; // We have to separate these test cases in order to separate the table and chart presentation if the table is filtered by the chart staticCodeAnalysisCategoriesForTable: StaticCodeAnalysisCategory[] = []; diff --git a/src/main/webapp/content/icons/icons.ts b/src/main/webapp/content/icons/icons.ts index f5588768724b..cabc6c1d4613 100644 --- a/src/main/webapp/content/icons/icons.ts +++ b/src/main/webapp/content/icons/icons.ts @@ -42,8 +42,23 @@ export const facSaveWarning: IconDefinition = { ], } as IconDefinition; +export const facDetails: IconDefinition = { + prefix: 'fac' as IconPrefix, + iconName: 'details' as IconName, + icon: [ + 24, // SVG view box width + 24, // SVG view box height + [], + '', + [ + 'M2.25 21C2.25 21.4125 2.5875 21.75 3 21.75H12.1424C13.196 22.3848 14.4303 22.75 15.75 22.75C16.3886 22.75 17.0073 22.6645 17.5951 22.5043C17.0752 23.3981 16.1068 24 15 24H3C1.34531 24 0 22.6547 0 21V3C0 1.34531 1.34531 0 3 0H10.7578C11.5547 0 12.3141 0.314063 12.8766 0.876563L17.1234 5.11875C17.6859 5.68125 18 6.44531 18 7.24219V9.11945C17.2939 8.87991 16.5371 8.75 15.75 8.75V7.5H12C11.1703 7.5 10.5 6.82969 10.5 6V2.25H3C2.5875 2.25 2.25 2.5875 2.25 3V21Z M9.83815 12H5.625C5.00156 12 4.5 12.5016 4.5 13.125C4.5 13.7484 5.00156 14.25 5.625 14.25H8.91109C9.08828 13.4384 9.40626 12.6795 9.83815 12Z M8.78971 16.5H5.625C5.00156 16.5 4.5 17.0016 4.5 17.625C4.5 18.2484 5.00156 18.75 5.625 18.75H9.42363C9.09363 18.0553 8.87467 17.2977 8.78971 16.5Z M21.3756 15.6867C21.3756 16.9416 20.9681 18.1008 20.2818 19.0413L23.7436 22.5052C24.0855 22.8469 24.0855 23.4019 23.7436 23.7437C23.4018 24.0854 22.8467 24.0854 22.5049 23.7437L19.043 20.2797C18.1023 20.9687 16.9429 21.3733 15.6878 21.3733C12.5458 21.3733 10 18.828 10 15.6867C10 12.5453 12.5458 10 15.6878 10C18.8297 10 21.3756 12.5453 21.3756 15.6867ZM15.6878 19.6236C16.2049 19.6236 16.7169 19.5218 17.1947 19.3239C17.6724 19.1261 18.1065 18.8361 18.4721 18.4705C18.8378 18.1049 19.1278 17.6709 19.3257 17.1933C19.5236 16.7156 19.6255 16.2037 19.6255 15.6867C19.6255 15.1697 19.5236 14.6577 19.3257 14.1801C19.1278 13.7024 18.8378 13.2684 18.4721 12.9028C18.1065 12.5373 17.6724 12.2473 17.1947 12.0494C16.7169 11.8516 16.2049 11.7497 15.6878 11.7497C15.1707 11.7497 14.6586 11.8516 14.1809 12.0494C13.7031 12.2473 13.2691 12.5373 12.9034 12.9028C12.5378 13.2684 12.2477 13.7024 12.0498 14.1801C11.8519 14.6577 11.7501 15.1697 11.7501 15.6867C11.7501 16.2037 11.8519 16.7156 12.0498 17.1933C12.2477 17.6709 12.5378 18.1049 12.9034 18.4705C13.2691 18.8361 13.7031 19.1261 14.1809 19.3239C14.6586 19.5218 15.1707 19.6236 15.6878 19.6236Z', + ], + ], +} as IconDefinition; + export const artemisIconPack: IconPack = { facSidebar, facSaveSuccess, facSaveWarning, + facDetails, }; diff --git a/src/main/webapp/i18n/de/programmingExercise.json b/src/main/webapp/i18n/de/programmingExercise.json index 41533417cf1e..9a84f1b04a8e 100644 --- a/src/main/webapp/i18n/de/programmingExercise.json +++ b/src/main/webapp/i18n/de/programmingExercise.json @@ -357,6 +357,7 @@ "filter": "Filter", "noData": "Es konnten keine Feedback Einträge für die Programmieraufgabe gefunden werden.", "noDataFilter": "Für den spezifizierten Filter oder Suchbegriff konnten keine Feedback Einträge gefunden werden.", + "channelSuccess": "Kanal {{channelName}} erfolgreich erstellt.", "feedbackModal": { "header": "Fehlerdetails", "feedbackTitle": "Feedback zu Testfällen", @@ -379,6 +380,36 @@ "repository": "Repository", "totalItems": "Insgesamt {{count}} Element(e)", "error": "Beim Laden der betroffenen Studierenden ist ein Fehler aufgetreten." + }, + "feedbackDetailChannel": { + "title": "Erstelle einen Feedback-Diskussionskanal", + "studentNumber": "Anzahl der Studierende die dem Kanal hinzugefügt werden: {{count}}", + "description": "Dieser Kanal soll Diskussionen über spezifisches Feedback zur bereitgestellten Übung erleichtern. Es ermöglicht allen betroffenen Studierenden und Lehrenden, zusammenzuarbeiten und das Feedback effektiv anzugehen.", + "label": "Kanalname", + "channelVisibility": "Kanal Sichtbarkeit", + "visibilityPublic": "Öffentlich", + "visibilityPrivate": "Privat", + "announcementChannel": "Ankündigungskanal", + "announcementChannelYes": "Ja", + "announcementChannelNo": "Nein", + "createAndNavigateLabel": "Erstellen und Navigieren", + "createAndNavigateDescription": "Erstellt den Kanal und navigiert automatisch zum Kanal.", + "createChannelLabel": "Kanal erstellen:", + "createChannelDescription": "Erstellt den Kanal, ohne die Seite zu verlassen.", + "createChannelButton": "Kanal erstellen", + "placeholder": "Dieses Feld bitte nicht leer lassen", + "descriptionLabel": "Kanalbeschreibung", + "requiredValidationErrorName": "Der Kanalname ist ein Pflichtfeld.", + "maxLengthValidationErrorName": "Kanalname kann max {{ max }} Zeichen lang sein!", + "regexValidationErrorName": "Namen können nur Kleinbuchstaben, Zahlen und Striche enthalten. Nur Artemis kann Kanäle erstellen, die mit $ beginnen.", + "requiredValidationErrorDescription": "Die Kanalbeschreibung ist ein Pflichtfeld.", + "maxLengthValidationErrorDescription": "Kanalbeschreibung kann max {{ max }} Zeichen lang sein!", + "confirmationModal": { + "header": "Kanal Erstellen bestätigen", + "confirmationMessage": "Bist du sicher, dass du diesen Kanal erstellen möchten? Dies wird einen Kanal mit {{count}} Student(en) erstellen.", + "cancel": "Abbrechen", + "confirm": "Erstellen" + } } }, "help": { diff --git a/src/main/webapp/i18n/en/programmingExercise.json b/src/main/webapp/i18n/en/programmingExercise.json index d3a53f20f180..feff9adb303d 100644 --- a/src/main/webapp/i18n/en/programmingExercise.json +++ b/src/main/webapp/i18n/en/programmingExercise.json @@ -357,6 +357,7 @@ "filter": "Filters", "noData": "No Feedback Entries could be found for the Programming Exercise.", "noDataFilter": "No Feedback Entries could be found for the specified filter or search Term.", + "channelSuccess": "Channel {{channelName}} successfully created.", "feedbackModal": { "header": "Error Details", "feedbackTitle": "Test Case Feedback", @@ -379,6 +380,36 @@ "repository": "Repository", "totalItems": "In total {{count}} item(s)", "error": "There was an error while loading the affected Students for this feedback." + }, + "feedbackDetailChannel": { + "title": "Create Feedback Discussion Channel", + "studentNumber": "Number of students which will be added to the channel: {{count}}", + "description": "This channel is intended to facilitate discussions around specific feedback for the provided exercise feedback. It will allow all affected students and instructors to collaborate and address the feedback effectively.", + "label": "Channel Name", + "channelVisibility": "Channel Visibility", + "visibilityPublic": "Public", + "visibilityPrivate": "Private", + "announcementChannel": "Announcement Channel", + "announcementChannelYes": "Yes", + "announcementChannelNo": "No", + "createAndNavigateLabel": "Create and Navigate", + "createAndNavigateDescription": "Creates the channel and automatically navigates to the channel.", + "createChannelLabel": "Create Channel:", + "createChannelDescription": "Creates the channel without navigating away from this page.", + "createChannelButton": "Create Channel", + "placeholder": "This field should not be empty", + "descriptionLabel": "Description", + "requiredValidationErrorName": "The channel name is required.", + "maxLengthValidationErrorName": "Channel name can be max {{ max }} characters long!", + "regexValidationErrorName": "Names can only contain lowercase letters, numbers, and dashes. Only Artemis can create channels that start with a $.", + "requiredValidationErrorDescription": "The channel description is required.", + "maxLengthValidationErrorDescription": "Channel description can be max {{ max }} characters long!", + "confirmationModal": { + "header": "Confirm Channel Creation", + "confirmationMessage": "Are you sure you want to create this channel? This will create a channel with {{count}} student(s).", + "cancel": "Cancel", + "confirm": "Create" + } } }, "help": { diff --git a/src/test/java/de/tum/cit/aet/artemis/assessment/ResultServiceIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/assessment/ResultServiceIntegrationTest.java index 6b02b3e4d247..b5834d6a66bf 100644 --- a/src/test/java/de/tum/cit/aet/artemis/assessment/ResultServiceIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/assessment/ResultServiceIntegrationTest.java @@ -903,4 +903,43 @@ void testGetParticipationForFeedbackId() throws Exception { assertThat(jsonNode.has("empty")).isTrue(); } + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCountAffectedStudentsByFeedbackDetailText() throws Exception { + StudentParticipation participation1 = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student1"); + StudentParticipation participation2 = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student2"); + Result result1 = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation1); + Result result2 = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation2); + participationUtilService.addVariousFeedbackTypeFeedbacksToResult(result1); + ProgrammingExerciseTestCase testCase = programmingExerciseUtilService.addTestCaseToProgrammingExercise(programmingExercise, "test1"); + testCase.setId(1L); + + Feedback feedback1 = new Feedback(); + feedback1.setPositive(false); + feedback1.setDetailText("SampleFeedback"); + feedback1.setTestCase(testCase); + participationUtilService.addFeedbackToResult(feedback1, result1); + + Feedback feedback2 = new Feedback(); + feedback2.setPositive(false); + feedback2.setDetailText("SampleFeedback"); + feedback2.setTestCase(testCase); + participationUtilService.addFeedbackToResult(feedback2, result2); + + String url = "/api/exercises/" + programmingExercise.getId() + "/feedback-detail/affected-students?detailText=SampleFeedback"; + long affectedStudentsCount = request.get(url, HttpStatus.OK, Long.class); + + assertThat(affectedStudentsCount).isInstanceOf(Long.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCountAffectedStudentsByFeedbackDetailText_NoMatch() throws Exception { + ProgrammingExercise programmingExercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course); + + String url = "/api/exercises/" + programmingExercise.getId() + "/feedback-detail/affected-students?detailText=NonexistentFeedback"; + long affectedStudentsCount = request.get(url, HttpStatus.OK, Long.class); + + assertThat(affectedStudentsCount).isEqualTo(0); + } } diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java index c0aa140ae963..3bb88f191080 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java @@ -1,6 +1,7 @@ package de.tum.cit.aet.artemis.communication; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Fail.fail; import java.time.ZonedDateTime; import java.util.Arrays; @@ -22,6 +23,7 @@ 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.dto.MetisCrudAction; import de.tum.cit.aet.artemis.communication.util.ConversationUtilService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -32,6 +34,8 @@ import de.tum.cit.aet.artemis.lecture.domain.Lecture; import de.tum.cit.aet.artemis.lecture.repository.LectureRepository; import de.tum.cit.aet.artemis.lecture.util.LectureUtilService; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.util.ProgrammingExerciseUtilService; import de.tum.cit.aet.artemis.text.domain.TextExercise; import de.tum.cit.aet.artemis.text.util.TextExerciseUtilService; import de.tum.cit.aet.artemis.tutorialgroup.service.TutorialGroupChannelManagementService; @@ -63,6 +67,9 @@ class ChannelIntegrationTest extends AbstractConversationTest { @Autowired private ConversationUtilService conversationUtilService; + @Autowired + private ProgrammingExerciseUtilService programmingExerciseUtilService; + @BeforeEach @Override void setupTestScenario() throws Exception { @@ -901,6 +908,60 @@ void getLectureChannel_asCourseStudent_shouldGetLectureChannel() throws Exceptio lectureRepository.deleteById(lecture.getId()); } + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "STUDENT") + void createFeedbackChannel_asStudent_shouldReturnForbidden() { + Course course = programmingExerciseUtilService.addCourseWithOneProgrammingExercise(); + ProgrammingExercise programmingExercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course); + + ChannelDTO channelDTO = new ChannelDTO(); + channelDTO.setName("feedback-channel"); + channelDTO.setDescription("Discussion channel for feedback"); + channelDTO.setIsPublic(true); + channelDTO.setIsAnnouncementChannel(false); + + FeedbackChannelRequestDTO feedbackChannelRequest = new FeedbackChannelRequestDTO(channelDTO, "Sample feedback text"); + + String BASE_ENDPOINT = "api/courses/{courseId}/{exerciseId}/feedback-channel"; + + try { + request.postWithoutResponseBody(BASE_ENDPOINT.replace("{courseId}", course.getId().toString()).replace("{exerciseId}", programmingExercise.getId().toString()), + feedbackChannelRequest, HttpStatus.FORBIDDEN); + } + catch (Exception e) { + fail("There was an error executing the post request.", e); + } + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void createFeedbackChannel_asInstructor_shouldCreateChannel() { + Long courseId = 1L; + Long exerciseId = 1L; + ChannelDTO channelDTO = new ChannelDTO(); + channelDTO.setName("feedback-channel"); + channelDTO.setDescription("Discussion channel for feedback"); + channelDTO.setIsPublic(true); + channelDTO.setIsAnnouncementChannel(false); + + FeedbackChannelRequestDTO feedbackChannelRequest = new FeedbackChannelRequestDTO(channelDTO, "Sample feedback text"); + + String BASE_ENDPOINT = "/api/courses/{courseId}/{exerciseId}/feedback-channel"; + + ChannelDTO response = null; + try { + response = request.postWithResponseBody(BASE_ENDPOINT.replace("{courseId}", courseId.toString()).replace("{exerciseId}", exerciseId.toString()), feedbackChannelRequest, + ChannelDTO.class, HttpStatus.CREATED); + } + catch (Exception e) { + fail("Failed to create feedback channel", e); + } + + assertThat(response).isNotNull(); + assertThat(response.getName()).isEqualTo("feedback-channel"); + assertThat(response.getDescription()).isEqualTo("Discussion channel for feedback"); + } + private void testArchivalChangeWorks(ChannelDTO channel, boolean isPublicChannel, boolean shouldArchive) throws Exception { // prepare channel in db if (shouldArchive) { diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts index 09d22ab65dd6..efba3a7ba4de 100644 --- a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts @@ -9,6 +9,10 @@ import { LocalStorageService } from 'ngx-webstorage'; import '@angular/localize/init'; import { FeedbackFilterModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-filter-modal.component'; import { AffectedStudentsModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-affected-students-modal.component'; +import { FeedbackDetailChannelModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component'; +import { Subject } from 'rxjs'; +import { ChannelDTO } from 'app/entities/metis/conversation/channel.model'; +import { AlertService } from 'app/core/util/alert.service'; describe('FeedbackAnalysisComponent', () => { let fixture: ComponentFixture; @@ -16,6 +20,10 @@ describe('FeedbackAnalysisComponent', () => { let feedbackAnalysisService: FeedbackAnalysisService; let searchSpy: jest.SpyInstance; let localStorageService: LocalStorageService; + let modalService: NgbModal; + let alertService: AlertService; + let modalSpy: jest.SpyInstance; + let createChannelSpy: jest.SpyInstance; const feedbackMock: FeedbackDetail[] = [ { @@ -63,11 +71,27 @@ describe('FeedbackAnalysisComponent', () => { component = fixture.componentInstance; feedbackAnalysisService = fixture.debugElement.injector.get(FeedbackAnalysisService); localStorageService = fixture.debugElement.injector.get(LocalStorageService); + modalService = fixture.debugElement.injector.get(NgbModal); + alertService = fixture.debugElement.injector.get(AlertService); jest.spyOn(localStorageService, 'retrieve').mockReturnValue([]); searchSpy = jest.spyOn(feedbackAnalysisService, 'search').mockResolvedValue(feedbackResponseMock); + const mockFormSubmitted = new Subject<{ channelDto: ChannelDTO; navigate: boolean }>(); + modalSpy = jest.spyOn(TestBed.inject(NgbModal), 'open').mockReturnValue({ + componentInstance: { + formSubmitted: mockFormSubmitted, + affectedStudentsCount: null, + feedbackDetail: null, + }, + result: Promise.resolve(), + } as any); + + jest.spyOn(feedbackAnalysisService, 'getAffectedStudentCount').mockResolvedValue(10); + createChannelSpy = jest.spyOn(feedbackAnalysisService, 'createChannel').mockResolvedValue({ id: 123 } as ChannelDTO); + + jest.spyOn(fixture.debugElement.injector.get(AlertService), 'success'); + jest.spyOn(fixture.debugElement.injector.get(AlertService), 'error'); - // Initial input setup fixture.componentRef.setInput('exerciseId', 1); fixture.componentRef.setInput('exerciseTitle', 'Sample Exercise Title'); @@ -254,4 +278,44 @@ describe('FeedbackAnalysisComponent', () => { expect(modalSpy).toHaveBeenCalledOnce(); }); }); + + it('should open the feedback detail channel modal', async () => { + const formSubmitted = new Subject<{ channelDto: ChannelDTO; navigate: boolean }>(); + const modalRef = { + result: Promise.resolve('mocked result'), + componentInstance: { + formSubmitted, + affectedStudentsCount: null, + feedbackDetail: null, + }, + } as any; + jest.spyOn(modalService, 'open').mockReturnValue(modalRef); + await component.openFeedbackDetailChannelModal(feedbackMock[0]); + expect(modalService.open).toHaveBeenCalledWith(FeedbackDetailChannelModalComponent, { centered: true, size: 'lg' }); + }); + + it('should handle errors during channel creation gracefully', async () => { + const formSubmitted = new Subject<{ channelDto: ChannelDTO; navigate: boolean }>(); + const modalRef = { + result: Promise.resolve('mocked result'), + componentInstance: { + formSubmitted, + affectedStudentsCount: null, + feedbackDetail: null, + }, + } as any; + jest.spyOn(modalService, 'open').mockReturnValue(modalRef); + createChannelSpy.mockRejectedValue(new Error('Error creating channel')); + await component.openFeedbackDetailChannelModal(feedbackMock[0]); + formSubmitted.next({ channelDto: { name: 'Test Channel' } as ChannelDTO, navigate: true }); + expect(alertService.error).toHaveBeenCalledOnce(); + }); + + it('should not proceed if modal is already open', async () => { + component['isFeedbackDetailChannelModalOpen'] = true; + const feedbackDetail = feedbackMock[0]; + await component.openFeedbackDetailChannelModal(feedbackDetail); + expect(component['isFeedbackDetailChannelModalOpen']).toBeTrue(); + expect(modalSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts index 23b9bfaf0f49..45bb903fe421 100644 --- a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts @@ -132,4 +132,61 @@ describe('FeedbackAnalysisService', () => { expect(result.content[1].firstName).toBe('Jane'); }); }); + + describe('getAffectedStudentCount', () => { + it('should retrieve the count of affected students for a feedback detail text', async () => { + const exerciseId = 1; + const feedbackDetailText = 'Test feedback detail'; + const affectedStudentCountMock = 42; + + const responsePromise = service.getAffectedStudentCount(exerciseId, feedbackDetailText); + + const req = httpMock.expectOne(`api/exercises/${exerciseId}/feedback-detail/affected-students?detailText=${encodeURIComponent(feedbackDetailText)}`); + expect(req.request.method).toBe('GET'); + req.flush(affectedStudentCountMock); + + const result = await responsePromise; + expect(result).toBe(affectedStudentCountMock); + }); + }); + + describe('createChannel', () => { + it('should send a POST request to create a feedback-specific channel and return the created channel DTO', async () => { + const courseId = 1; + const exerciseId = 2; + + const channelDtoMock = { + name: 'feedback-channel', + description: 'Discussion channel for feedback', + isPublic: true, + isAnnouncementChannel: false, + }; + + const feedbackChannelRequestMock = { + channel: channelDtoMock, + feedbackDetailText: 'Sample feedback detail text', + }; + + const createdChannelMock = { + id: 1001, + name: 'feedback-channel', + description: 'Discussion channel for feedback', + isPublic: true, + isAnnouncementChannel: false, + }; + + const responsePromise = service.createChannel(courseId, exerciseId, feedbackChannelRequestMock); + + const req = httpMock.expectOne(`api/courses/${courseId}/${exerciseId}/feedback-channel`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(feedbackChannelRequestMock); + + req.flush(createdChannelMock); + + const result = await responsePromise; + expect(result).toEqual(createdChannelMock); + expect(result.name).toBe('feedback-channel'); + expect(result.description).toBe('Discussion channel for feedback'); + }); + }); }); diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/confirm-feedback-channel-creation-modal.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/confirm-feedback-channel-creation-modal.component.spec.ts new file mode 100644 index 000000000000..5c59597134d6 --- /dev/null +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/confirm-feedback-channel-creation-modal.component.spec.ts @@ -0,0 +1,39 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ConfirmFeedbackChannelCreationModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('ConfirmFeedbackChannelCreationModalComponent', () => { + let fixture: ComponentFixture; + let component: ConfirmFeedbackChannelCreationModalComponent; + let activeModal: NgbActiveModal; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), ConfirmFeedbackChannelCreationModalComponent], + providers: [NgbActiveModal], + }).compileComponents(); + + fixture = TestBed.createComponent(ConfirmFeedbackChannelCreationModalComponent); + component = fixture.componentInstance; + activeModal = TestBed.inject(NgbActiveModal); + fixture.componentRef.setInput('affectedStudentsCount', 42); + fixture.detectChanges(); + }); + + it('should initialize with the provided affectedStudentsCount', () => { + expect(component.affectedStudentsCount()).toBe(42); + }); + + it('should call close on activeModal with true when confirm is triggered', () => { + const closeSpy = jest.spyOn(activeModal, 'close'); + component.confirm(); + expect(closeSpy).toHaveBeenCalledExactlyOnceWith(true); + }); + + it('should call dismiss on activeModal when dismiss is triggered', () => { + const dismissSpy = jest.spyOn(activeModal, 'dismiss'); + component.dismiss(); + expect(dismissSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-detail-channel-modal.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-detail-channel-modal.component.spec.ts new file mode 100644 index 000000000000..74928d400dd3 --- /dev/null +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-detail-channel-modal.component.spec.ts @@ -0,0 +1,153 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FeedbackDetailChannelModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component'; +import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('FeedbackDetailChannelModalComponent', () => { + let fixture: ComponentFixture; + let component: FeedbackDetailChannelModalComponent; + let activeModal: NgbActiveModal; + let modalService: NgbModal; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), ReactiveFormsModule, FeedbackDetailChannelModalComponent], + providers: [NgbActiveModal, NgbModal, FormBuilder], + }).compileComponents(); + + fixture = TestBed.createComponent(FeedbackDetailChannelModalComponent); + component = fixture.componentInstance; + activeModal = TestBed.inject(NgbActiveModal); + modalService = TestBed.inject(NgbModal); + + fixture.componentRef.setInput('affectedStudentsCount', 42); + fixture.componentRef.setInput('feedbackDetail', { + detailText: 'Sample feedback', + concatenatedFeedbackIds: [1], + count: 10, + relativeCount: 50, + testCaseName: 'testCase1', + taskName: 'Task 1', + errorCategory: 'StudentError', + } as any); + fixture.componentInstance.isConfirmModalOpen.set(false); + fixture.detectChanges(); + }); + + it('should initialize form and inputs', () => { + expect(component.affectedStudentsCount()).toBe(42); + expect(component.feedbackDetail().detailText).toBe('Sample feedback'); + expect(component.form).toBeDefined(); + expect(component.form.valid).toBeFalse(); + }); + + it('should call activeModal.close when closeModal is triggered', () => { + const closeSpy = jest.spyOn(activeModal, 'close'); + component.closeModal(); + expect(closeSpy).toHaveBeenCalledOnce(); + }); + + it('should call activeModal.dismiss when dismissModal is triggered', () => { + const dismissSpy = jest.spyOn(activeModal, 'dismiss'); + component.dismissModal(); + expect(dismissSpy).toHaveBeenCalledOnce(); + }); + + it('should open confirmation modal and emit formSubmitted on successful confirmation', async () => { + jest.spyOn(component, 'handleModal').mockResolvedValue(true); + + component.form.setValue({ + name: 'channel', + description: 'channelDescription', + isPublic: true, + isAnnouncementChannel: false, + }); + + const formSubmittedSpy = jest.spyOn(component.formSubmitted, 'emit'); + await component.submitForm(false); + + expect(component.isConfirmModalOpen()).toBeFalse(); + expect(formSubmittedSpy).toHaveBeenCalledExactlyOnceWith({ + channelDto: expect.objectContaining({ + creationDate: undefined, + creator: undefined, + description: 'channelDescription', + hasChannelModerationRights: undefined, + hasUnreadMessage: undefined, + id: undefined, + isAnnouncementChannel: false, + isArchived: undefined, + isChannelModerator: undefined, + isCourseWide: undefined, + isCreator: undefined, + isFavorite: undefined, + isHidden: undefined, + isMember: undefined, + isMuted: undefined, + isPublic: true, + lastMessageDate: undefined, + lastReadDate: undefined, + name: 'channel', + numberOfMembers: undefined, + subType: undefined, + subTypeReferenceId: undefined, + topic: undefined, + tutorialGroupId: undefined, + tutorialGroupTitle: undefined, + type: 'channel', + unreadMessagesCount: undefined, + }), + navigate: false, + }); + }); + + it('should call handleModal and proceed if confirmed', async () => { + jest.spyOn(component, 'handleModal').mockResolvedValue(true); + const formSubmittedSpy = jest.spyOn(component.formSubmitted, 'emit'); + + component.form.setValue({ + name: 'channel', + description: 'channelDescription', + isPublic: true, + isAnnouncementChannel: false, + }); + + await component.submitForm(false); + + expect(component.handleModal).toHaveBeenCalledOnce(); + expect(formSubmittedSpy).toHaveBeenCalledExactlyOnceWith({ + channelDto: expect.objectContaining({ + name: 'channel', + description: 'channelDescription', + isPublic: true, + isAnnouncementChannel: false, + }), + navigate: false, + }); + }); + + it('should not proceed if modal is dismissed', async () => { + jest.spyOn(component, 'handleModal').mockResolvedValue(false); + + const formSubmittedSpy = jest.spyOn(component.formSubmitted, 'emit'); + + component.form.setValue({ + name: 'channel', + description: 'channelDescription', + isPublic: true, + isAnnouncementChannel: false, + }); + + await component.submitForm(false); + + expect(component.handleModal).toHaveBeenCalledOnce(); + expect(formSubmittedSpy).not.toHaveBeenCalledOnce(); + }); + + it('should not open confirmation modal if form is invalid', async () => { + const modalSpy = jest.spyOn(modalService, 'open'); + await component.submitForm(true); + expect(modalSpy).not.toHaveBeenCalledOnce(); + }); +});