From 8442b3dc59606a23ec0dcde8109a33dff5749800 Mon Sep 17 00:00:00 2001 From: Paul Rangger <48455539+PaRangger@users.noreply.github.com> Date: Fri, 20 Dec 2024 13:59:57 +0100 Subject: [PATCH 01/40] Communication: Add shortcut to private messages on usernames (#10007) --- .../conversation/OneToOneChatResource.java | 56 ++++- .../answer-post/answer-post.component.html | 3 +- .../conversations/one-to-one-chat.service.ts | 6 + .../metis/metis-conversation.service.ts | 2 + .../webapp/app/shared/metis/metis.service.ts | 3 +- .../app/shared/metis/post/post.component.html | 1 + .../app/shared/metis/post/post.component.ts | 30 +-- .../answer-post-header.component.html | 2 +- .../post-header/post-header.component.html | 2 +- .../posting-header.directive.ts | 12 +- .../app/shared/metis/posting.directive.ts | 55 +++++ .../OneToOneChatIntegrationTest.java | 81 +++++-- .../metis-conversation.service.spec.ts | 26 +++ .../services/one-to-one-chat.service.spec.ts | 84 ++++--- .../answer-post/answer-post.component.spec.ts | 16 +- .../shared/metis/post/post.component.spec.ts | 1 + .../spec/directive/posting.directive.spec.ts | 218 +++++++++++++++++- .../mock-metis-conversation.service.ts | 8 + 18 files changed, 509 insertions(+), 97 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/OneToOneChatResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/OneToOneChatResource.java index a5fe0e2c7356..b7825b920da7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/OneToOneChatResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/conversation/OneToOneChatResource.java @@ -25,6 +25,8 @@ import de.tum.cit.aet.artemis.communication.service.conversation.OneToOneChatService; import de.tum.cit.aet.artemis.communication.service.conversation.auth.OneToOneChatAuthorizationService; 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.CourseRepository; import de.tum.cit.aet.artemis.core.repository.UserRepository; @@ -66,7 +68,8 @@ public OneToOneChatResource(SingleUserNotificationService singleUserNotification * * @param courseId the id of the course * @param otherChatParticipantLogins logins of other participants (must be 1 for one to one chat) excluding the requesting user - * @return ResponseEntity with status 201 (Created) and with body containing the created one to one chat + * + * @return ResponseEntity according to createOneToOneChat function */ @PostMapping("{courseId}/one-to-one-chats") @EnforceAtLeastStudent @@ -74,8 +77,8 @@ public ResponseEntity startOneToOneChat(@PathVariable Long cour var requestingUser = userRepository.getUserWithGroupsAndAuthorities(); log.debug("REST request to create one to one chat in course {} between : {} and : {}", courseId, requestingUser.getLogin(), otherChatParticipantLogins); var course = courseRepository.findByIdElseThrow(courseId); - checkMessagingEnabledElseThrow(course); - oneToOneChatAuthorizationService.isAllowedToCreateOneToOneChat(course, requestingUser); + + validateInputElseThrow(requestingUser, course); var loginsToSearchFor = new HashSet<>(otherChatParticipantLogins); loginsToSearchFor.add(requestingUser.getLogin()); @@ -88,8 +91,53 @@ public ResponseEntity startOneToOneChat(@PathVariable Long cour var userA = chatMembers.getFirst(); var userB = chatMembers.get(1); - var oneToOneChat = oneToOneChatService.startOneToOneChat(course, userA, userB); var userToBeNotified = userA.getLogin().equals(requestingUser.getLogin()) ? userB : userA; + return createOneToOneChat(requestingUser, userToBeNotified, course); + } + + /** + * POST /api/courses/:courseId/one-to-one-chats/:userId: Starts a new one to one chat in a course + * + * @param courseId the id of the course + * @param userId the id of the participating user + * + * @return ResponseEntity according to createOneToOneChat function + */ + @PostMapping("{courseId}/one-to-one-chats/{userId}") + @EnforceAtLeastStudent + public ResponseEntity startOneToOneChat(@PathVariable Long courseId, @PathVariable Long userId) throws URISyntaxException { + var requestingUser = userRepository.getUserWithGroupsAndAuthorities(); + var otherUser = userRepository.findByIdElseThrow(userId); + log.debug("REST request to create one to one chat by id in course {} between : {} and : {}", courseId, requestingUser.getLogin(), otherUser.getLogin()); + var course = courseRepository.findByIdElseThrow(courseId); + + validateInputElseThrow(requestingUser, course); + + return createOneToOneChat(requestingUser, otherUser, course); + } + + /** + * Function to validate incoming request data + * + * @param requestingUser user that wants to create the one to one chat + * @param course course to create the one to one chat + */ + private void validateInputElseThrow(User requestingUser, Course course) { + checkMessagingEnabledElseThrow(course); + oneToOneChatAuthorizationService.isAllowedToCreateOneToOneChat(course, requestingUser); + } + + /** + * Function to create a one to one chat and return the corresponding response to the client + * + * @param requestingUser user that wants to create the one to one chat + * @param userToBeNotified user that is invited into the one to one chat + * @param course course to create the one to one chat + * + * @return ResponseEntity with status 201 (Created) and with body containing the created one to one chat + */ + private ResponseEntity createOneToOneChat(User requestingUser, User userToBeNotified, Course course) throws URISyntaxException { + var oneToOneChat = oneToOneChatService.startOneToOneChat(course, requestingUser, userToBeNotified); singleUserNotificationService.notifyClientAboutConversationCreationOrDeletion(oneToOneChat, userToBeNotified, requestingUser, NotificationType.CONVERSATION_CREATE_ONE_TO_ONE_CHAT); // also send notification to the author in order for the author to subscribe to the new chat (this notification won't be saved and shown to author) diff --git a/src/main/webapp/app/shared/metis/answer-post/answer-post.component.html b/src/main/webapp/app/shared/metis/answer-post/answer-post.component.html index 05258758872f..537e0a527b01 100644 --- a/src/main/webapp/app/shared/metis/answer-post/answer-post.component.html +++ b/src/main/webapp/app/shared/metis/answer-post/answer-post.component.html @@ -24,6 +24,7 @@ [isCommunicationPage]="isCommunicationPage" [lastReadDate]="lastReadDate" [isDeleted]="isDeleted" + (onUserNameClicked)="onUserNameClicked()" /> } @@ -39,7 +40,7 @@ [isDeleted]="isDeleted" [deleteTimerInSeconds]="deleteTimerInSeconds" (onUndoDeleteEvent)="onDeleteEvent(false)" - (userReferenceClicked)="userReferenceClicked.emit($event)" + (userReferenceClicked)="onUserReferenceClicked($event)" (channelReferenceClicked)="channelReferenceClicked.emit($event)" />
diff --git a/src/main/webapp/app/shared/metis/conversations/one-to-one-chat.service.ts b/src/main/webapp/app/shared/metis/conversations/one-to-one-chat.service.ts index 92ed92e33bf2..03b2a5b3dee3 100644 --- a/src/main/webapp/app/shared/metis/conversations/one-to-one-chat.service.ts +++ b/src/main/webapp/app/shared/metis/conversations/one-to-one-chat.service.ts @@ -19,4 +19,10 @@ export class OneToOneChatService { .post(`${this.resourceUrl}${courseId}/one-to-one-chats`, [loginOfChatPartner], { observe: 'response' }) .pipe(map(this.conversationService.convertDateFromServer)); } + + createWithId(courseId: number, userIdOfChatPartner: number) { + return this.http + .post(`${this.resourceUrl}${courseId}/one-to-one-chats/${userIdOfChatPartner}`, null, { observe: 'response' }) + .pipe(map(this.conversationService.convertDateFromServer)); + } } diff --git a/src/main/webapp/app/shared/metis/metis-conversation.service.ts b/src/main/webapp/app/shared/metis/metis-conversation.service.ts index b21b4074294d..216d4d49afdb 100644 --- a/src/main/webapp/app/shared/metis/metis-conversation.service.ts +++ b/src/main/webapp/app/shared/metis/metis-conversation.service.ts @@ -210,6 +210,8 @@ export class MetisConversationService implements OnDestroy { public createOneToOneChat = (loginOfChatPartner: string): Observable> => this.onConversationCreation(this.oneToOneChatService.create(this._courseId, loginOfChatPartner)); + public createOneToOneChatWithId = (userId: number): Observable> => + this.onConversationCreation(this.oneToOneChatService.createWithId(this._courseId, userId)); public createChannel = (channel: ChannelDTO) => this.onConversationCreation(this.channelService.create(this._courseId, channel)); public createGroupChat = (loginsOfChatPartners: string[]) => this.onConversationCreation(this.groupChatService.create(this._courseId, loginsOfChatPartners)); private onConversationCreation = (creation$: Observable>): Observable => { diff --git a/src/main/webapp/app/shared/metis/metis.service.ts b/src/main/webapp/app/shared/metis/metis.service.ts index a4415a2d1cee..da43d6ba258f 100644 --- a/src/main/webapp/app/shared/metis/metis.service.ts +++ b/src/main/webapp/app/shared/metis/metis.service.ts @@ -44,7 +44,6 @@ export class MetisService implements OnDestroy { private currentConversation?: ConversationDTO = undefined; private user: User; private pageType: PageType; - private course: Course; private courseId: number; private cachedPosts: Post[] = []; private cachedTotalNumberOfPosts: number; @@ -53,6 +52,8 @@ export class MetisService implements OnDestroy { private courseWideTopicSubscription: Subscription; private savedPostService: SavedPostService = inject(SavedPostService); + course: Course; + constructor( protected postService: PostService, protected answerPostService: AnswerPostService, diff --git a/src/main/webapp/app/shared/metis/post/post.component.html b/src/main/webapp/app/shared/metis/post/post.component.html index a9620cd7e925..ddc77434d323 100644 --- a/src/main/webapp/app/shared/metis/post/post.component.html +++ b/src/main/webapp/app/shared/metis/post/post.component.html @@ -24,6 +24,7 @@ [isDeleted]="isDeleted" [isCommunicationPage]="isCommunicationPage" (isModalOpen)="displayInlineInput = true" + (onUserNameClicked)="onUserNameClicked()" [lastReadDate]="lastReadDate" />
diff --git a/src/main/webapp/app/shared/metis/post/post.component.ts b/src/main/webapp/app/shared/metis/post/post.component.ts index feeb30d3ed0d..39e1b04e0fa0 100644 --- a/src/main/webapp/app/shared/metis/post/post.component.ts +++ b/src/main/webapp/app/shared/metis/post/post.component.ts @@ -23,10 +23,7 @@ import { ContextInformation, DisplayPriority, PageType, RouteComponents } from ' import { faBookmark, faBullhorn, faCheckSquare, faComments, faPencilAlt, faSmile, faThumbtack, faTrash } from '@fortawesome/free-solid-svg-icons'; import dayjs from 'dayjs/esm'; import { PostFooterComponent } from 'app/shared/metis/posting-footer/post-footer/post-footer.component'; -import { OneToOneChatService } from 'app/shared/metis/conversations/one-to-one-chat.service'; -import { isCommunicationEnabled, isMessagingEnabled } from 'app/entities/course.model'; -import { Router } from '@angular/router'; -import { MetisConversationService } from 'app/shared/metis/metis-conversation.service'; +import { isCommunicationEnabled } from 'app/entities/course.model'; import { getAsChannelDTO } from 'app/entities/metis/conversation/channel.model'; import { AnswerPost } from 'app/entities/metis/answer-post.model'; import { AnswerPostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/answer-post-create-edit-modal/answer-post-create-edit-modal.component'; @@ -98,9 +95,6 @@ export class PostComponent extends PostingDirective implements OnInit, OnC constructor( public metisService: MetisService, public changeDetector: ChangeDetectorRef, - private oneToOneChatService: OneToOneChatService, - private metisConversationService: MetisConversationService, - private router: Router, public renderer: Renderer2, @Inject(DOCUMENT) private document: Document, ) { @@ -255,28 +249,6 @@ export class PostComponent extends PostingDirective implements OnInit, OnC ); } - /** - * Create a or navigate to one-to-one chat with the referenced user - * - * @param referencedUserLogin login of the referenced user - */ - onUserReferenceClicked(referencedUserLogin: string) { - const course = this.metisService.getCourse(); - if (isMessagingEnabled(course)) { - if (this.isCommunicationPage) { - this.metisConversationService.createOneToOneChat(referencedUserLogin).subscribe(); - } else { - this.oneToOneChatService.create(course.id!, referencedUserLogin).subscribe((res) => { - this.router.navigate(['courses', course.id, 'communication'], { - queryParams: { - conversationId: res.body!.id, - }, - }); - }); - } - } - } - /** * Navigate to the referenced channel * diff --git a/src/main/webapp/app/shared/metis/posting-header/answer-post-header/answer-post-header.component.html b/src/main/webapp/app/shared/metis/posting-header/answer-post-header/answer-post-header.component.html index f7d42ac92f61..cc197006ce3a 100644 --- a/src/main/webapp/app/shared/metis/posting-header/answer-post-header/answer-post-header.component.html +++ b/src/main/webapp/app/shared/metis/posting-header/answer-post-header/answer-post-header.component.html @@ -15,7 +15,7 @@ > - {{ posting.author?.name }} + {{ posting.author?.name }} diff --git a/src/main/webapp/app/shared/metis/posting-header/post-header/post-header.component.html b/src/main/webapp/app/shared/metis/posting-header/post-header/post-header.component.html index deadcb377cdd..ac20f91f82a2 100644 --- a/src/main/webapp/app/shared/metis/posting-header/post-header/post-header.component.html +++ b/src/main/webapp/app/shared/metis/posting-header/post-header/post-header.component.html @@ -15,7 +15,7 @@ > - {{ posting.author?.name }} + {{ posting.author?.name }} diff --git a/src/main/webapp/app/shared/metis/posting-header/posting-header.directive.ts b/src/main/webapp/app/shared/metis/posting-header/posting-header.directive.ts index 8868f60124c1..0325610f669a 100644 --- a/src/main/webapp/app/shared/metis/posting-header/posting-header.directive.ts +++ b/src/main/webapp/app/shared/metis/posting-header/posting-header.directive.ts @@ -1,5 +1,5 @@ import { Posting } from 'app/entities/metis/posting.model'; -import { Directive, EventEmitter, Input, OnInit, Output, inject, input } from '@angular/core'; +import { Directive, EventEmitter, Input, OnInit, Output, inject, input, output } from '@angular/core'; import dayjs from 'dayjs/esm'; import { MetisService } from 'app/shared/metis/metis.service'; import { UserRole } from 'app/shared/metis/metis.util'; @@ -19,6 +19,8 @@ export abstract class PostingHeaderDirective implements OnIni isDeleted = input(false); + readonly onUserNameClicked = output(); + isAtLeastTutorInCourse: boolean; isAuthorOfPosting: boolean; postingIsOfToday: boolean; @@ -99,4 +101,12 @@ export abstract class PostingHeaderDirective implements OnIni this.userAuthorityTooltip = 'artemisApp.metis.userAuthorityTooltips.deleted'; } } + + protected userNameClicked() { + if (this.isAuthorOfPosting || !this.posting.authorRole) { + return; + } + + this.onUserNameClicked.emit(); + } } diff --git a/src/main/webapp/app/shared/metis/posting.directive.ts b/src/main/webapp/app/shared/metis/posting.directive.ts index f62af4907e18..71c40bf0b12e 100644 --- a/src/main/webapp/app/shared/metis/posting.directive.ts +++ b/src/main/webapp/app/shared/metis/posting.directive.ts @@ -4,6 +4,10 @@ import { MetisService } from 'app/shared/metis/metis.service'; import { DisplayPriority } from 'app/shared/metis/metis.util'; import { faBookmark } from '@fortawesome/free-solid-svg-icons'; import { faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons'; +import { isMessagingEnabled } from 'app/entities/course.model'; +import { OneToOneChatService } from 'app/shared/metis/conversations/one-to-one-chat.service'; +import { MetisConversationService } from 'app/shared/metis/metis-conversation.service'; +import { Router } from '@angular/router'; @Directive() export abstract class PostingDirective implements OnInit, OnDestroy { @@ -28,8 +32,11 @@ export abstract class PostingDirective implements OnInit, OnD content?: string; + protected oneToOneChatService = inject(OneToOneChatService); + protected metisConversationService = inject(MetisConversationService); protected metisService = inject(MetisService); protected changeDetector = inject(ChangeDetectorRef); + protected router = inject(Router); // Icons farBookmark = farBookmark; @@ -131,4 +138,52 @@ export abstract class PostingDirective implements OnInit, OnD this.posting.isSaved = true; } } + + /** + * Create a or navigate to one-to-one chat with the referenced user + * + * @param referencedUserLogin login of the referenced user + */ + onUserReferenceClicked(referencedUserLogin: string) { + const course = this.metisService.course; + if (isMessagingEnabled(course)) { + if (this.isCommunicationPage) { + this.metisConversationService.createOneToOneChat(referencedUserLogin).subscribe(); + } else { + this.oneToOneChatService.create(course.id!, referencedUserLogin).subscribe((res) => { + this.router.navigate(['courses', course.id, 'communication'], { + queryParams: { + conversationId: res.body!.id, + }, + }); + }); + } + } + } + + /** + * Create a or navigate to one-to-one chat with the referenced user + */ + onUserNameClicked() { + if (!this.posting.author?.id) { + return; + } + + const referencedUserId = this.posting.author?.id; + + const course = this.metisService.course; + if (isMessagingEnabled(course)) { + if (this.isCommunicationPage) { + this.metisConversationService.createOneToOneChatWithId(referencedUserId).subscribe(); + } else { + this.oneToOneChatService.createWithId(course.id!, referencedUserId).subscribe((res) => { + this.router.navigate(['courses', course.id, 'communication'], { + queryParams: { + conversationId: res.body!.id, + }, + }); + }); + } + } + } } diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/OneToOneChatIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/OneToOneChatIntegrationTest.java index 5c8c90be7165..e6caa14684a6 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/OneToOneChatIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/OneToOneChatIntegrationTest.java @@ -49,7 +49,7 @@ String getTestPrefix() { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void startOneToOneChat_asStudent1_shouldCreateMultipleOneToOneChats() throws Exception { + void shouldCreateMultipleOneToOneChatsWhenDifferentLoginsAreProvided() throws Exception { // when var chat1 = request.postWithResponseBody("/api/courses/" + exampleCourseId + "/one-to-one-chats", List.of(testPrefix + "student2"), OneToOneChatDTO.class, HttpStatus.CREATED); @@ -67,7 +67,7 @@ void startOneToOneChat_asStudent1_shouldCreateMultipleOneToOneChats() throws Exc @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void startOneToOneChat_asStudent2WithStudent1_shouldUseExistingOneToOneChat() throws Exception { + void shouldUseExistingOneToOneChatWhenChatAlreadyExists() throws Exception { var chat = request.postWithResponseBody("/api/courses/" + exampleCourseId + "/one-to-one-chats", List.of(testPrefix + "student2"), OneToOneChatDTO.class, HttpStatus.CREATED); @@ -88,13 +88,9 @@ void startOneToOneChat_asStudent2WithStudent1_shouldUseExistingOneToOneChat() th @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void startOneToOneChat_invalidNumberOfChatPartners_shouldReturnBadRequest() throws Exception { - // chat with too many users - // then + void shouldReturnBadRequestWhenSupplyingInsufficientAmountOfLogins() throws Exception { request.postWithResponseBody("/api/courses/" + exampleCourseId + "/one-to-one-chats", List.of(testPrefix + "student2", testPrefix + "student3"), OneToOneChatDTO.class, HttpStatus.BAD_REQUEST); - // chat with too few users - // then request.postWithResponseBody("/api/courses/" + exampleCourseId + "/one-to-one-chats", List.of(), OneToOneChatDTO.class, HttpStatus.BAD_REQUEST); verifyNoParticipantTopicWebsocketSent(); @@ -103,9 +99,8 @@ void startOneToOneChat_invalidNumberOfChatPartners_shouldReturnBadRequest() thro @ParameterizedTest @EnumSource(value = CourseInformationSharingConfiguration.class, names = { "COMMUNICATION_ONLY", "DISABLED" }) @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void startOneToOneChat_messagingFeatureDeactivated_shouldReturnForbidden(CourseInformationSharingConfiguration courseInformationSharingConfiguration) throws Exception { + void shouldReturnForbiddenWhenMessagingIsDisabled(CourseInformationSharingConfiguration courseInformationSharingConfiguration) throws Exception { startOneToOneChat_messagingDeactivated(courseInformationSharingConfiguration); - } void startOneToOneChat_messagingDeactivated(CourseInformationSharingConfiguration courseInformationSharingConfiguration) throws Exception { @@ -119,33 +114,28 @@ void startOneToOneChat_messagingDeactivated(CourseInformationSharingConfiguratio @Test @WithMockUser(username = TEST_PREFIX + "student42", roles = "USER") - void startOneToOneChat_notAllowedAsNotStudentInCourse_shouldReturnBadRequest() throws Exception { - // then + void shouldReturnBadRequestWhenStudentIsNotAllowedInCourse() throws Exception { request.postWithResponseBody("/api/courses/" + exampleCourseId + "/one-to-one-chats", List.of(testPrefix + "student2"), OneToOneChatDTO.class, HttpStatus.FORBIDDEN); } @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void startOneToOneChat_chatAlreadyExists_shouldReturnExistingChat() throws Exception { - // when + void shouldReturnExistingChatWhenChatAlreadyExists() throws Exception { var chat = request.postWithResponseBody("/api/courses/" + exampleCourseId + "/one-to-one-chats", List.of(testPrefix + "student2"), OneToOneChatDTO.class, HttpStatus.CREATED); var chat2 = request.postWithResponseBody("/api/courses/" + exampleCourseId + "/one-to-one-chats", List.of(testPrefix + "student2"), OneToOneChatDTO.class, HttpStatus.CREATED); - // then assertThat(chat).isNotNull(); assertThat(chat2).isNotNull(); assertThat(chat.getId()).isEqualTo(chat2.getId()); assertParticipants(chat.getId(), 2, "student1", "student2"); - // members of the created one to one chat are only notified in case the first message within the conversation is created verifyNoParticipantTopicWebsocketSent(); - } @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void postInOneToOneChat_firstPost_chatPartnerShouldBeNotifiedAboutNewConversation() throws Exception { + void shouldNotifyChatPartnerAboutNewConversationWhenChatIsStarted() throws Exception { // when var chat = request.postWithResponseBody("/api/courses/" + exampleCourseId + "/one-to-one-chats", List.of(testPrefix + "student2"), OneToOneChatDTO.class, HttpStatus.CREATED); @@ -157,4 +147,61 @@ void postInOneToOneChat_firstPost_chatPartnerShouldBeNotifiedAboutNewConversatio verifyNoParticipantTopicWebsocketSentExceptAction(MetisCrudAction.CREATE, MetisCrudAction.NEW_MESSAGE); } + + // PR + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void shouldCreateOneToOneChatWhenRequestedWithUserId() throws Exception { + Long student2Id = userRepository.findOneByLogin(testPrefix + "student2").orElseThrow().getId(); + + var chat = request.postWithResponseBody("/api/courses/" + exampleCourseId + "/one-to-one-chats/" + student2Id, null, OneToOneChatDTO.class, HttpStatus.CREATED); + + assertThat(chat).isNotNull(); + assertParticipants(chat.getId(), 2, "student1", "student2"); + verifyNoParticipantTopicWebsocketSent(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void shouldReturnExistingChatWhenRequestedWithUserIdAndChatExists() throws Exception { + Long student2Id = userRepository.findOneByLogin(testPrefix + "student2").orElseThrow().getId(); + + var chat1 = request.postWithResponseBody("/api/courses/" + exampleCourseId + "/one-to-one-chats/" + student2Id, null, OneToOneChatDTO.class, HttpStatus.CREATED); + + var chat2 = request.postWithResponseBody("/api/courses/" + exampleCourseId + "/one-to-one-chats/" + student2Id, null, OneToOneChatDTO.class, HttpStatus.CREATED); + + assertThat(chat1).isNotNull(); + assertThat(chat2).isNotNull(); + assertThat(chat1.getId()).isEqualTo(chat2.getId()); + assertParticipants(chat1.getId(), 2, "student1", "student2"); + verifyNoParticipantTopicWebsocketSent(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void shouldReturnNotFoundWhenUnknownUserIdIsPassed() throws Exception { + request.postWithResponseBody("/api/courses/" + exampleCourseId + "/one-to-one-chats/99999", null, OneToOneChatDTO.class, HttpStatus.NOT_FOUND); + } + + @ParameterizedTest + @EnumSource(value = CourseInformationSharingConfiguration.class, names = { "COMMUNICATION_ONLY", "DISABLED" }) + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void shouldReturnForbiddenWhenMessagingIsDisabledAndUserIdIsSupplied(CourseInformationSharingConfiguration courseInformationSharingConfiguration) throws Exception { + Long student2Id = userRepository.findOneByLogin(testPrefix + "student2").orElseThrow().getId(); + + setCourseInformationSharingConfiguration(courseInformationSharingConfiguration); + + request.postWithResponseBody("/api/courses/" + exampleCourseId + "/one-to-one-chats/" + student2Id, null, OneToOneChatDTO.class, HttpStatus.FORBIDDEN); + + setCourseInformationSharingConfiguration(CourseInformationSharingConfiguration.COMMUNICATION_AND_MESSAGING); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student42", roles = "USER") + void shouldReturnForbiddenWhenStudentIsNotInCourse() throws Exception { + Long student2Id = userRepository.findOneByLogin(testPrefix + "student2").orElseThrow().getId(); + + request.postWithResponseBody("/api/courses/" + exampleCourseId + "/one-to-one-chats/" + student2Id, null, OneToOneChatDTO.class, HttpStatus.FORBIDDEN); + } } diff --git a/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts index 489aa6a853d2..b397fe2951d3 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts @@ -269,6 +269,32 @@ describe('MetisConversationService', () => { }); }); + it('should set active conversation to newly created one to one chat when calling with id', () => { + return new Promise((done) => { + metisConversationService.setUpConversationService(course).subscribe({ + complete: () => { + const newOneToOneChat = generateOneToOneChatDTO({ id: 99 }); + const createOneToOneChatSpy = jest.spyOn(oneToOneChatService, 'createWithId').mockReturnValue(of(new HttpResponse({ body: newOneToOneChat }))); + const getConversationSpy = jest + .spyOn(conversationService, 'getConversationsOfUser') + .mockReturnValue(of(new HttpResponse({ body: [groupChat, oneToOneChat, channel, newOneToOneChat] }))); + createOneToOneChatSpy.mockClear(); + metisConversationService.createOneToOneChatWithId(1).subscribe({ + complete: () => { + expect(createOneToOneChatSpy).toHaveBeenCalledOnce(); + expect(createOneToOneChatSpy).toHaveBeenCalledWith(course.id, 1); + metisConversationService.activeConversation$.subscribe((activeConversation) => { + expect(activeConversation).toBe(newOneToOneChat); + expect(getConversationSpy).toHaveBeenCalledTimes(2); + done({}); + }); + }, + }); + }, + }); + }); + }); + it('should add new conversation to conversations of user on conversation create received', () => { return new Promise((done) => { metisConversationService.setUpConversationService(course).subscribe({ diff --git a/src/test/javascript/spec/component/overview/course-conversations/services/one-to-one-chat.service.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/services/one-to-one-chat.service.spec.ts index 6514ba2c0458..c0e3d42d4291 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/services/one-to-one-chat.service.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/services/one-to-one-chat.service.spec.ts @@ -1,53 +1,73 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { take } from 'rxjs/operators'; -import { generateOneToOneChatDTO } from '../helpers/conversationExampleModels'; -import { TranslateService } from '@ngx-translate/core'; -import { MockAccountService } from '../../../../helpers/mocks/service/mock-account.service'; -import { MockTranslateService } from '../../../../helpers/mocks/service/mock-translate.service'; -import { AccountService } from 'app/core/auth/account.service'; +import { TestBed } from '@angular/core/testing'; import { OneToOneChatService } from 'app/shared/metis/conversations/one-to-one-chat.service'; import { OneToOneChatDTO } from 'app/entities/metis/conversation/one-to-one-chat.model'; -import { NotificationService } from 'app/shared/notification/notification.service'; -import { MockNotificationService } from '../../../../helpers/mocks/service/mock-notification.service'; +import { ConversationService } from 'app/shared/metis/conversations/conversation.service'; import { provideHttpClient } from '@angular/common/http'; +import dayjs from 'dayjs/esm'; describe('OneToOneChatService', () => { let service: OneToOneChatService; let httpMock: HttpTestingController; - let elemDefault: OneToOneChatDTO; + let conversationServiceMock: jest.Mocked; beforeEach(() => { + conversationServiceMock = { + convertDateFromServer: jest.fn((response) => response), + } as any; + TestBed.configureTestingModule({ - imports: [], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: TranslateService, useClass: MockTranslateService }, - { provide: AccountService, useClass: MockAccountService }, - { provide: NotificationService, useClass: MockNotificationService }, - ], + providers: [OneToOneChatService, provideHttpClient(), provideHttpClientTesting(), { provide: ConversationService, useValue: conversationServiceMock }], }); + service = TestBed.inject(OneToOneChatService); httpMock = TestBed.inject(HttpTestingController); - - elemDefault = generateOneToOneChatDTO({}); }); afterEach(() => { httpMock.verify(); }); - it('create', fakeAsync(() => { - const returnedFromService = { ...elemDefault, id: 0 }; - const expected = { ...returnedFromService }; - service - .create(1, 'login') - .pipe(take(1)) - .subscribe((resp) => expect(resp).toMatchObject({ body: expected })); - - const req = httpMock.expectOne({ method: 'POST' }); - req.flush(returnedFromService); - tick(); - })); + describe('create method', () => { + it('should create a one-to-one chat with a login', () => { + const courseId = 1; + const loginOfChatPartner = 'testuser'; + const mockResponse: OneToOneChatDTO = { + id: 1, + creationDate: dayjs(), + }; + + service.create(courseId, loginOfChatPartner).subscribe((response) => { + expect(response.body).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(`/api/courses/${courseId}/one-to-one-chats`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual([loginOfChatPartner]); + req.flush(mockResponse); + expect(conversationServiceMock.convertDateFromServer).toHaveBeenCalled(); + }); + }); + + describe('createWithId method', () => { + it('should create a one-to-one chat with a user ID', () => { + const courseId = 1; + const userIdOfChatPartner = 42; + const mockResponse: OneToOneChatDTO = { + id: 1, + creationDate: dayjs(), + }; + + service.createWithId(courseId, userIdOfChatPartner).subscribe((response) => { + expect(response.body).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(`/api/courses/${courseId}/one-to-one-chats/${userIdOfChatPartner}`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toBeNull(); + req.flush(mockResponse); + + expect(conversationServiceMock.convertDateFromServer).toHaveBeenCalled(); + }); + }); }); diff --git a/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts b/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts index 45badc3c912d..5895889c9f6e 100644 --- a/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/answer-post/answer-post.component.spec.ts @@ -18,12 +18,18 @@ import { MockMetisService } from '../../../../helpers/mocks/service/mock-metis-s import { Posting, PostingType } from 'app/entities/metis/posting.model'; import { AnswerPost } from 'app/entities/metis/answer-post.model'; import dayjs from 'dayjs/esm'; -import { ArtemisDatePipe } from '../../../../../../../main/webapp/app/shared/pipes/artemis-date.pipe'; -import { ArtemisTranslatePipe } from '../../../../../../../main/webapp/app/shared/pipes/artemis-translate.pipe'; -import { TranslateDirective } from '../../../../../../../main/webapp/app/shared/language/translate.directive'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; import { MockTranslateService } from '../../../../helpers/mocks/service/mock-translate.service'; import { TranslateService } from '@ngx-translate/core'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { MockSyncStorage } from '../../../../helpers/mocks/service/mock-sync-storage.service'; +import { SessionStorageService } from 'ngx-webstorage'; +import { MetisConversationService } from 'app/shared/metis/metis-conversation.service'; +import { MockMetisConversationService } from '../../../../helpers/mocks/service/mock-metis-conversation.service'; describe('AnswerPostComponent', () => { let component: AnswerPostComponent; @@ -50,9 +56,13 @@ describe('AnswerPostComponent', () => { MockDirective(TranslateDirective), ], providers: [ + provideHttpClient(), + provideHttpClientTesting(), { provide: DOCUMENT, useValue: document }, { provide: MetisService, useClass: MockMetisService }, { provide: TranslateService, useClass: MockTranslateService }, + { provide: SessionStorageService, useClass: MockSyncStorage }, + { provide: MetisConversationService, useClass: MockMetisConversationService }, ], }) .compileComponents() diff --git a/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts b/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts index 3f37a29a0d23..275950975bc4 100644 --- a/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/post/post.component.spec.ts @@ -93,6 +93,7 @@ describe('PostComponent', () => { .then(() => { fixture = TestBed.createComponent(PostComponent); metisService = TestBed.inject(MetisService); + metisService.course = metisCourse; component = fixture.componentInstance; debugElement = fixture.debugElement; diff --git a/src/test/javascript/spec/directive/posting.directive.spec.ts b/src/test/javascript/spec/directive/posting.directive.spec.ts index ab17325e9b18..0591cf0ded17 100644 --- a/src/test/javascript/spec/directive/posting.directive.spec.ts +++ b/src/test/javascript/spec/directive/posting.directive.spec.ts @@ -4,12 +4,36 @@ import { Posting } from 'app/entities/metis/posting.model'; import { DisplayPriority } from 'app/shared/metis/metis.util'; import { PostingDirective } from 'app/shared/metis/posting.directive'; import { MetisService } from 'app/shared/metis/metis.service'; +import { MockTranslateService } from '../helpers/mocks/service/mock-translate.service'; +import { MockSyncStorage } from '../helpers/mocks/service/mock-sync-storage.service'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { TranslateService } from '@ngx-translate/core'; +import { SessionStorageService } from 'ngx-webstorage'; +import { MetisConversationService } from 'app/shared/metis/metis-conversation.service'; +import { MockMetisConversationService } from '../helpers/mocks/service/mock-metis-conversation.service'; +import { of } from 'rxjs'; +import { OneToOneChatService } from 'app/shared/metis/conversations/one-to-one-chat.service'; +import { Router } from '@angular/router'; +import { Course } from 'app/entities/course.model'; +import { MockProvider } from 'ng-mocks'; +import { User } from 'app/core/user/user.model'; +import * as courseModel from 'app/entities/course.model'; + +class MockOneToOneChatService { + createWithId = jest.fn().mockReturnValue(of({ body: { id: 1 } })); + create = jest.fn().mockReturnValue(of({ body: { id: 1 } })); +} class MockPosting implements Posting { + id: number; content: string; + author?: User; - constructor(content: string) { + constructor(id: number, content: string, author: User) { + this.id = id; this.content = content; + this.author = author; } } @@ -21,8 +45,6 @@ class MockReactionsBar { selectReaction = jest.fn(); } -class MockMetisService {} - @Component({ template: `
`, }) @@ -42,23 +64,48 @@ describe('PostingDirective', () => { let component: TestPostingComponent; let fixture: ComponentFixture; let mockReactionsBar: MockReactionsBar; + let mockMetisService: MetisService; + let mockOneToOneChatService: OneToOneChatService; + let mockMetisConversationService: MetisConversationService; + let mockRouter: Router; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [TestPostingComponent], - providers: [{ provide: MetisService, useClass: MockMetisService }], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: TranslateService, useClass: MockTranslateService }, + { provide: SessionStorageService, useClass: MockSyncStorage }, + MockProvider(MetisService), + { provide: MetisConversationService, useClass: MockMetisConversationService }, + { provide: OneToOneChatService, useClass: MockOneToOneChatService }, + { provide: Router, useValue: { navigate: jest.fn() } }, + ], }).compileComponents(); - }); - beforeEach(() => { fixture = TestBed.createComponent(TestPostingComponent); component = fixture.componentInstance; + jest.mock('app/entities/course.model', () => ({ + ...jest.requireActual('app/entities/course.model'), + isMessagingEnabled: jest.fn(), + })); mockReactionsBar = new MockReactionsBar(); component.reactionsBar = mockReactionsBar; - component.posting = new MockPosting('Test content'); + const user = new User(); + user.id = 123; + component.posting = new MockPosting(123, 'Test content', user); component.isCommunicationPage = false; component.isThreadSidebar = false; fixture.detectChanges(); + + mockMetisService = TestBed.inject(MetisService); + const course = new Course(); + course.id = 1; + mockMetisService.course = course; + mockOneToOneChatService = TestBed.inject(OneToOneChatService); + mockMetisConversationService = TestBed.inject(MetisConversationService); + mockRouter = TestBed.inject(Router); }); afterEach(() => { @@ -126,4 +173,161 @@ describe('PostingDirective', () => { component.toggleEmojiSelect(); expect(component.showReactionSelector).toBeFalse(); }); + + it('should not proceed in onUserNameClicked if author is not set', () => { + const isMessagingEnabledSpy = jest.spyOn(courseModel, 'isMessagingEnabled').mockReturnValue(true); + + component.posting.author = undefined; + component.onUserNameClicked(); + + expect(isMessagingEnabledSpy).not.toHaveBeenCalled(); + }); + + it('should not proceed in onUserNameClicked if messaging is not enabled', () => { + jest.spyOn(courseModel, 'isMessagingEnabled').mockReturnValue(false); + const createOneToOneChatSpy = jest.spyOn(mockMetisConversationService, 'createOneToOneChatWithId'); + const createChatSpy = jest.spyOn(mockOneToOneChatService, 'createWithId'); + const navigateSpy = jest.spyOn(mockRouter, 'navigate'); + + component.onUserNameClicked(); + + expect(createOneToOneChatSpy).not.toHaveBeenCalled(); + expect(createChatSpy).not.toHaveBeenCalled(); + expect(navigateSpy).not.toHaveBeenCalled(); + }); + + it('should not proceed in onUserReferenceClicked if messaging is not enabled', () => { + jest.spyOn(courseModel, 'isMessagingEnabled').mockReturnValue(false); + const createOneToOneChatSpy = jest.spyOn(mockMetisConversationService, 'createOneToOneChat'); + const createChatSpy = jest.spyOn(mockOneToOneChatService, 'create'); + const navigateSpy = jest.spyOn(mockRouter, 'navigate'); + + component.onUserReferenceClicked('test'); + + expect(createOneToOneChatSpy).not.toHaveBeenCalled(); + expect(createChatSpy).not.toHaveBeenCalled(); + expect(navigateSpy).not.toHaveBeenCalled(); + }); + + it('should create one-to-one chat in onUserNameClicked when messaging is enabled', () => { + jest.spyOn(courseModel, 'isMessagingEnabled').mockReturnValue(true); + component.isCommunicationPage = true; + + const createOneToOneChatIdSpy = jest.spyOn(mockMetisConversationService, 'createOneToOneChatWithId'); + const createWithIdSpy = jest.spyOn(mockOneToOneChatService, 'createWithId'); + + component.onUserNameClicked(); + + expect(createOneToOneChatIdSpy).toHaveBeenCalledWith(123); + + component.isCommunicationPage = false; + + component.onUserNameClicked(); + + expect(createWithIdSpy).toHaveBeenCalledWith(1, 123); + }); + + it('should create one-to-one chat in onUserReferenceClicked when messaging is enabled', () => { + jest.spyOn(courseModel, 'isMessagingEnabled').mockReturnValue(true); + component.isCommunicationPage = true; + + const createOneToOneChatSpy = jest.spyOn(mockMetisConversationService, 'createOneToOneChat'); + const createSpy = jest.spyOn(mockOneToOneChatService, 'create'); + + component.onUserReferenceClicked('test'); + + expect(createOneToOneChatSpy).toHaveBeenCalledWith('test'); + + component.isCommunicationPage = false; + + component.onUserReferenceClicked('test'); + + expect(createSpy).toHaveBeenCalledWith(1, 'test'); + }); + + it('should set isDeleted to true when delete event is triggered', () => { + component.onDeleteEvent(true); + expect(component.isDeleted).toBeTrue(); + }); + + it('should set isDeleted to false when delete event is false', () => { + component.onDeleteEvent(false); + expect(component.isDeleted).toBeFalse(); + }); + + it('should clear existing delete timer and interval before setting up new ones', () => { + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + component.deleteTimer = setTimeout(() => {}, 1000); + component.deleteInterval = setInterval(() => {}, 1000); + + component.onDeleteEvent(true); + + expect(clearTimeoutSpy).toHaveBeenCalledOnce(); + expect(clearIntervalSpy).toHaveBeenCalledOnce(); + }); + + it('should set delete timer to initial value when delete is true', () => { + component.onDeleteEvent(true); + expect(component.deleteTimerInSeconds).toBe(component.timeToDeleteInSeconds); + }); + + it('should call metisService.deletePost for regular post', () => { + const deletePostSpy = jest.spyOn(mockMetisService, 'deletePost'); + jest.useFakeTimers(); + + component.isAnswerPost = false; + component.onDeleteEvent(true); + + jest.runOnlyPendingTimers(); + + expect(deletePostSpy).toHaveBeenCalledWith(component.posting); + }); + + it('should call metisService.deleteAnswerPost for answer post', () => { + const deleteAnswerPostSpy = jest.spyOn(mockMetisService, 'deleteAnswerPost'); + jest.useFakeTimers(); + + component.isAnswerPost = true; + component.onDeleteEvent(true); + + jest.runOnlyPendingTimers(); + + expect(deleteAnswerPostSpy).toHaveBeenCalledWith(component.posting); + }); + + it('should set up interval to decrement delete timer', () => { + jest.useFakeTimers(); + + component.onDeleteEvent(true); + + jest.advanceTimersByTime(1000); + expect(component.deleteTimerInSeconds).toBe(5); + + jest.advanceTimersByTime(1000); + expect(component.deleteTimerInSeconds).toBe(4); + }); + + it('should stop timer at 0 when decrementing', () => { + jest.useFakeTimers(); + + component.onDeleteEvent(true); + + jest.advanceTimersByTime(7000); + + expect(component.deleteTimerInSeconds).toBe(0); + + jest.useRealTimers(); + }); + + it('should do nothing if delete event is false', () => { + const deletePostSpy = jest.spyOn(mockMetisService, 'deletePost'); + const deleteAnswerPostSpy = jest.spyOn(mockMetisService, 'deleteAnswerPost'); + + component.onDeleteEvent(false); + + expect(deletePostSpy).not.toHaveBeenCalled(); + expect(deleteAnswerPostSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-metis-conversation.service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-metis-conversation.service.ts index 7f85809374f3..fb023c652251 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-metis-conversation.service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-metis-conversation.service.ts @@ -40,6 +40,14 @@ export class MockMetisConversationService { return EMPTY; }; + createOneToOneChatWithId = (userId: number): Observable => { + return EMPTY; + }; + + createOneToOneChat = (userId: number): Observable => { + return EMPTY; + }; + forceRefresh(notifyActiveConversationSubscribers = true, notifyConversationsSubscribers = true): Observable { return EMPTY; } From e46ca220828fd0f8241a002dd290463b16868875 Mon Sep 17 00:00:00 2001 From: Mohamed Bilel Besrour <58034472+BBesrour@users.noreply.github.com> Date: Fri, 20 Dec 2024 14:00:46 +0100 Subject: [PATCH 02/40] Integrated code lifecycle: Show result progress bar in exam overview and exercise details pages (#10048) --- .../programming-test-status-detail.component.html | 1 + .../exam-exercise-overview-page.component.html | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/webapp/app/detail-overview-list/components/programming-test-status-detail/programming-test-status-detail.component.html b/src/main/webapp/app/detail-overview-list/components/programming-test-status-detail/programming-test-status-detail.component.html index 366ecdb77efd..78a22d3e39e8 100644 --- a/src/main/webapp/app/detail-overview-list/components/programming-test-status-detail/programming-test-status-detail.component.html +++ b/src/main/webapp/app/detail-overview-list/components/programming-test-status-detail/programming-test-status-detail.component.html @@ -7,6 +7,7 @@ [showUngradedResults]="true" [personalParticipation]="false" [short]="false" + [showProgressBar]="true" (onParticipationChange)="detail.data.onParticipationChange()" class="me-2" /> diff --git a/src/main/webapp/app/exam/participate/exercises/exercise-overview-page/exam-exercise-overview-page.component.html b/src/main/webapp/app/exam/participate/exercises/exercise-overview-page/exam-exercise-overview-page.component.html index 088d0de19f65..a779b0c1729b 100644 --- a/src/main/webapp/app/exam/participate/exercises/exercise-overview-page/exam-exercise-overview-page.component.html +++ b/src/main/webapp/app/exam/participate/exercises/exercise-overview-page/exam-exercise-overview-page.component.html @@ -66,7 +66,8 @@

[showBadge]="true" [participation]="item.exercise.studentParticipations[0]" [personalParticipation]="true" - class="me-2" + [showProgressBar]="true" + class="me-2 d-block" /> } From 4e94c0137ea08e1bd2f4e1103607bf03e339da65 Mon Sep 17 00:00:00 2001 From: Asli Aykan <56061820+asliayk@users.noreply.github.com> Date: Fri, 20 Dec 2024 14:01:47 +0100 Subject: [PATCH 03/40] Development: Migrate client code for emoji components and conversation services (#10021) --- .../shared/metis/conversations/channel.service.ts | 10 ++++------ .../metis/conversations/conversation.service.ts | 10 ++++------ .../metis/conversations/group-chat.service.ts | 10 ++++------ .../metis/conversations/one-to-one-chat.service.ts | 8 +++----- .../shared/metis/emoji/emoji-picker.component.html | 6 +++--- .../shared/metis/emoji/emoji-picker.component.ts | 14 +++++++++----- .../app/shared/metis/emoji/emoji.component.html | 4 ++-- .../app/shared/metis/emoji/emoji.component.ts | 7 +++++-- src/main/webapp/app/shared/metis/metis.module.ts | 4 ++-- 9 files changed, 36 insertions(+), 37 deletions(-) diff --git a/src/main/webapp/app/shared/metis/conversations/channel.service.ts b/src/main/webapp/app/shared/metis/conversations/channel.service.ts index 52e91237f14a..61105215ca1c 100644 --- a/src/main/webapp/app/shared/metis/conversations/channel.service.ts +++ b/src/main/webapp/app/shared/metis/conversations/channel.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { ChannelDTO, ChannelIdAndNameDTO } from 'app/entities/metis/conversation/channel.model'; @@ -10,11 +10,9 @@ import { AccountService } from 'app/core/auth/account.service'; export class ChannelService { public resourceUrl = '/api/courses/'; - constructor( - private http: HttpClient, - private conversationService: ConversationService, - private accountService: AccountService, - ) {} + private http = inject(HttpClient); + private conversationService = inject(ConversationService); + private accountService = inject(AccountService); getChannelsOfCourse(courseId: number): Observable> { return this.http.get(`${this.resourceUrl}${courseId}/channels/overview`, { diff --git a/src/main/webapp/app/shared/metis/conversations/conversation.service.ts b/src/main/webapp/app/shared/metis/conversations/conversation.service.ts index 5bf109a6a476..07d53e592613 100644 --- a/src/main/webapp/app/shared/metis/conversations/conversation.service.ts +++ b/src/main/webapp/app/shared/metis/conversations/conversation.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -34,11 +34,9 @@ export enum ConversationMemberSearchFilter { export class ConversationService { public resourceUrl = '/api/courses/'; - constructor( - protected http: HttpClient, - protected translationService: TranslateService, - protected accountService: AccountService, - ) {} + protected http = inject(HttpClient); + protected translationService = inject(TranslateService); + protected accountService = inject(AccountService); getConversationName(conversation: ConversationDTO | undefined, showLogin = false): string { if (!conversation) { diff --git a/src/main/webapp/app/shared/metis/conversations/group-chat.service.ts b/src/main/webapp/app/shared/metis/conversations/group-chat.service.ts index 16bf49e5727a..99ad62c9ce30 100644 --- a/src/main/webapp/app/shared/metis/conversations/group-chat.service.ts +++ b/src/main/webapp/app/shared/metis/conversations/group-chat.service.ts @@ -3,18 +3,16 @@ import { OneToOneChatDTO } from 'app/entities/metis/conversation/one-to-one-chat import { GroupChatDTO } from 'app/entities/metis/conversation/group-chat.model'; import { Observable, map } from 'rxjs'; import { HttpClient, HttpResponse } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { AccountService } from 'app/core/auth/account.service'; @Injectable({ providedIn: 'root' }) export class GroupChatService { public resourceUrl = 'api/courses/'; - constructor( - private http: HttpClient, - private conversationService: ConversationService, - private accountService: AccountService, - ) {} + private http = inject(HttpClient); + private conversationService = inject(ConversationService); + private accountService = inject(AccountService); create(courseId: number, loginsOfChatPartners: string[]): Observable> { return this.http diff --git a/src/main/webapp/app/shared/metis/conversations/one-to-one-chat.service.ts b/src/main/webapp/app/shared/metis/conversations/one-to-one-chat.service.ts index 03b2a5b3dee3..0931727370f4 100644 --- a/src/main/webapp/app/shared/metis/conversations/one-to-one-chat.service.ts +++ b/src/main/webapp/app/shared/metis/conversations/one-to-one-chat.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { ConversationService } from 'app/shared/metis/conversations/conversation.service'; @@ -9,10 +9,8 @@ import { OneToOneChatDTO } from 'app/entities/metis/conversation/one-to-one-chat export class OneToOneChatService { public resourceUrl = '/api/courses/'; - constructor( - private http: HttpClient, - private conversationService: ConversationService, - ) {} + private http = inject(HttpClient); + private conversationService = inject(ConversationService); create(courseId: number, loginOfChatPartner: string): Observable> { return this.http diff --git a/src/main/webapp/app/shared/metis/emoji/emoji-picker.component.html b/src/main/webapp/app/shared/metis/emoji/emoji-picker.component.html index 08cceca47792..411d4b08a986 100644 --- a/src/main/webapp/app/shared/metis/emoji/emoji-picker.component.html +++ b/src/main/webapp/app/shared/metis/emoji/emoji-picker.component.html @@ -1,11 +1,11 @@ boolean; - @Input() categoriesIcons: { [key: string]: string }; - @Input() recent: string[]; - @Output() emojiSelect: EventEmitter = new EventEmitter(); + recent = input(); + emojiSelect = output(); + emojisToShowFilter = input<(emoji: string | EmojiData) => boolean>(); + categoriesIcons = input<{ [key: string]: string }>({}); utils = EmojiUtils; dark = computed(() => this.themeService.currentTheme() === Theme.DARK); diff --git a/src/main/webapp/app/shared/metis/emoji/emoji.component.html b/src/main/webapp/app/shared/metis/emoji/emoji.component.html index ac71d2e4826a..b125034fd8c5 100644 --- a/src/main/webapp/app/shared/metis/emoji/emoji.component.html +++ b/src/main/webapp/app/shared/metis/emoji/emoji.component.html @@ -1,6 +1,6 @@ @if (!dark()) { - + } @else { - + } diff --git a/src/main/webapp/app/shared/metis/emoji/emoji.component.ts b/src/main/webapp/app/shared/metis/emoji/emoji.component.ts index 00e651bf19b7..7bb4700d17f6 100644 --- a/src/main/webapp/app/shared/metis/emoji/emoji.component.ts +++ b/src/main/webapp/app/shared/metis/emoji/emoji.component.ts @@ -1,4 +1,5 @@ -import { Component, Input, computed, inject } from '@angular/core'; +import { Component, computed, inject, input } from '@angular/core'; +import { EmojiModule } from '@ctrl/ngx-emoji-mart/ngx-emoji'; import { Theme, ThemeService } from 'app/core/theme/theme.service'; import { EmojiUtils } from 'app/shared/metis/emoji/emoji.utils'; @@ -6,12 +7,14 @@ import { EmojiUtils } from 'app/shared/metis/emoji/emoji.utils'; selector: 'jhi-emoji', templateUrl: './emoji.component.html', styleUrls: ['./emoji.component.scss'], + imports: [EmojiModule], + standalone: true, }) export class EmojiComponent { private themeService = inject(ThemeService); utils = EmojiUtils; - @Input() emoji: string; + emoji = input(''); dark = computed(() => this.themeService.currentTheme() === Theme.DARK); } diff --git a/src/main/webapp/app/shared/metis/metis.module.ts b/src/main/webapp/app/shared/metis/metis.module.ts index 114481b3a0df..49b1c9317cc0 100644 --- a/src/main/webapp/app/shared/metis/metis.module.ts +++ b/src/main/webapp/app/shared/metis/metis.module.ts @@ -65,6 +65,8 @@ import { ProfilePictureComponent } from 'app/shared/profile-picture/profile-pict LinkPreviewModule, ProfilePictureComponent, HtmlForPostingMarkdownPipe, + EmojiComponent, + EmojiPickerComponent, ], declarations: [ PostingThreadComponent, @@ -88,8 +90,6 @@ import { ProfilePictureComponent } from 'app/shared/profile-picture/profile-pict MessageInlineInputComponent, MessageReplyInlineInputComponent, ReactingUsersOnPostingPipe, - EmojiComponent, - EmojiPickerComponent, ], exports: [ PostingThreadComponent, From 42826a528a1da14aadc1ac23960bab3bb32edb4d Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Fri, 20 Dec 2024 16:19:54 +0100 Subject: [PATCH 04/40] Development: Update read the docs config (#10054) --- LICENSE | 2 +- docs/.readthedocs.yaml | 5 +++-- docs/admin/setup/distributed.rst | 2 +- docs/conf.py | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/LICENSE b/LICENSE index a7504c5ef00b..507cc7deda09 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 TUM Applied Software Engineering +Copyright (c) 2024 TUM Applied Education Technologies Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/docs/.readthedocs.yaml b/docs/.readthedocs.yaml index 4e14204d7703..332190edd7bd 100644 --- a/docs/.readthedocs.yaml +++ b/docs/.readthedocs.yaml @@ -2,11 +2,12 @@ version: 2 build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.12" + python: "3.13" sphinx: fail_on_warning: true + configuration: docs/conf.py python: install: - requirements: docs/requirements.txt diff --git a/docs/admin/setup/distributed.rst b/docs/admin/setup/distributed.rst index def7d2b7a980..5c6e5ce6248d 100644 --- a/docs/admin/setup/distributed.rst +++ b/docs/admin/setup/distributed.rst @@ -17,7 +17,7 @@ Setup with multiple instances There are certain scenarios, where a setup with multiple instances of the application server is required. This can e.g. be due to special requirements regarding fault tolerance or performance. -Artemis also supports this setup (which is also used at the Chair for Applied Software Engineering at TUM). +Artemis also supports this setup (which is also used at TUM). Multiple instances of the application server are used to distribute the load: diff --git a/docs/conf.py b/docs/conf.py index ec9f22d2b6ac..705a2a5e18db 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,8 +18,8 @@ # -- Project information ----------------------------------------------------- project = 'Artemis' -copyright = '2024, Technical University of Munich, Applied Software Engineering' -author = 'Technical University of Munich, Applied Software Engineering' +copyright = '2024, Applied Education Technologies, Technical University of Munich' +author = 'Applied Education Technologies, Technical University of Munich' # -- General configuration --------------------------------------------------- From 669422b825028b359725e0a6a611d9cc4645b3ef Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Fri, 20 Dec 2024 16:20:36 +0100 Subject: [PATCH 05/40] Development: Update server dependencies --- build.gradle | 4 ++-- gradle.properties | 2 +- src/main/webapp/app/lecture/lecture-update.component.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index 4091d7584bc7..7bf602580eaf 100644 --- a/build.gradle +++ b/build.gradle @@ -257,7 +257,7 @@ dependencies { implementation "org.apache.lucene:lucene-queryparser:${lucene_version}" implementation "org.apache.lucene:lucene-core:${lucene_version}" implementation "org.apache.lucene:lucene-analyzers-common:${lucene_version}" - implementation "com.google.protobuf:protobuf-java:4.29.1" + implementation "com.google.protobuf:protobuf-java:4.29.2" // we have to override those values to use the latest version implementation "org.slf4j:jcl-over-slf4j:${slf4j_version}" @@ -525,7 +525,7 @@ dependencies { } testImplementation "org.springframework.security:spring-security-test:${spring_security_version}" testImplementation "org.springframework.boot:spring-boot-test:${spring_boot_version}" - testImplementation "org.assertj:assertj-core:3.26.3" + testImplementation "org.assertj:assertj-core:3.27.0" testImplementation "org.mockito:mockito-core:${mockito_version}" testImplementation "org.mockito:mockito-junit-jupiter:${mockito_version}" diff --git a/gradle.properties b/gradle.properties index c110eb971ddc..c32073344f44 100644 --- a/gradle.properties +++ b/gradle.properties @@ -31,7 +31,7 @@ slf4j_version=2.0.16 sentry_version=7.19.0 liquibase_version=4.30.0 docker_java_version=3.4.1 -logback_version=1.5.12 +logback_version=1.5.14 java_parser_version=3.26.2 byte_buddy_version=1.15.11 netty_version=4.1.115.Final diff --git a/src/main/webapp/app/lecture/lecture-update.component.ts b/src/main/webapp/app/lecture/lecture-update.component.ts index c4ec628fc417..d4653758c979 100644 --- a/src/main/webapp/app/lecture/lecture-update.component.ts +++ b/src/main/webapp/app/lecture/lecture-update.component.ts @@ -15,7 +15,7 @@ import { ACCEPTED_FILE_EXTENSIONS_FILE_BROWSER, ALLOWED_FILE_EXTENSIONS_HUMAN_RE import { FormulaAction } from 'app/shared/monaco-editor/model/actions/formula.action'; import { LectureTitleChannelNameComponent } from './lecture-title-channel-name.component'; import { LectureUpdatePeriodComponent } from 'app/lecture/lecture-period/lecture-period.component'; -import dayjs, { Dayjs } from 'dayjs'; +import dayjs, { Dayjs } from 'dayjs/esm'; import { FormDateTimePickerComponent } from 'app/shared/date-time-picker/date-time-picker.component'; import cloneDeep from 'lodash-es/cloneDeep'; From 0ebce2e05ce021449e0107ac6ef5513eab9d6c2d Mon Sep 17 00:00:00 2001 From: Julian Waluschyk <37155504+julian-wls@users.noreply.github.com> Date: Fri, 20 Dec 2024 16:22:01 +0100 Subject: [PATCH 06/40] Communication: Add feature availability list to user documentation (#10015) --- docs/user/communication.rst | 234 ++++++++++++++++++++++++++++++ docs/user/mobile-applications.rst | 2 +- 2 files changed, 235 insertions(+), 1 deletion(-) diff --git a/docs/user/communication.rst b/docs/user/communication.rst index cf28dce9c8d8..026785e4861b 100644 --- a/docs/user/communication.rst +++ b/docs/user/communication.rst @@ -100,6 +100,240 @@ for multiple links. |link-preview-multiple| + +.. _communication features availability list: + +Communication Features Availability +----------------------------------- + +.. |AVAILABLE| raw:: html + + AVAILABLE + +.. |UNAVAILABLE| raw:: html + + UNAVAILABLE + +.. |PLANNED| raw:: html + + PLANNED + +.. |WIP| raw:: html + + WIP + +.. |NOT PLANNED| raw:: html + + NOT PLANNED + + +The following table represents the currently available communication features of Artemis on the different platforms. Note that not all +features are available to every user, which is why **Actor restrictions** have been added. The following sections will explore this in more +detail. + +Status explained +^^^^^^^^^^^^^^^^ + +.. list-table:: + :widths: 15 74 + + * - |AVAILABLE| + - This feature has been released to production. + * - |UNAVAILABLE| + - This feature is currently not available and not planned yet. + * - |PLANNED| + - This feature is planned and implemented within the next 2-4 months. + * - |WIP| + - This feature is currently being worked on and will be released soon. + * - |NOT PLANNED| + - This feature will not be implemented due to platform restrictions, or it does not make sense to implement it. + + + + +Available features on each platform +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Feature | Actor Restrictions | Web App | iOS | Android | ++======================================================+======================================+====================+=====================+=====================+ +| **General** | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Send Messages | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Receive Messages | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| **Post Actions** | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| React to Messages | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Reply in Thread | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Copy Text | | |NOT PLANNED| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Pin Messages | | Groups: group creators | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | +| | | Channels: moderators | | | | +| | | DM: members of DM | | | | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Delete Message | Moderators and authors | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Edit Message | Authors only | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Save Message for later | | |AVAILABLE| | |UNAVAILABLE| | |PLANNED| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Forward Messages | | |WIP| | |UNAVAILABLE| | |PLANNED| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Resolve Messages | At least tutor and authors | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Post action bar (thread view) | ||NOT PLANNED| | |AVAILABLE| | |WIP| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| **Markdown Textfield Options** | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Tag other users | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Reference channels, lectures and exercises | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Tag FAQ | | |AVAILABLE| | |WIP| | |PLANNED| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Basic formatting (underline, bold, italic) | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Strikethrough formatting | | |AVAILABLE| | |UNAVAILABLE| | |PLANNED| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Preview | | |AVAILABLE| | |UNAVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Code Block and inline code formatting | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Reference formatting | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Link formatting | | |AVAILABLE| | |AVAILABLE| | |PLANNED| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| **Messages** | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Profile pictures | | |AVAILABLE| | |AVAILABLE| | |WIP| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Show if message was edited, resolved or pinned | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | Render links to exercises, lectures, other chats, | | |AVAILABLE| | |AVAILABLE| | |WIP| | +| | lecture-units, slides, lecture-attachment with | | | | | +| | correct icon | | | | | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Render FAQ links | | |AVAILABLE| | |AVAILABLE| | |PLANNED| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Mark unread messages | | |UNAVAILABLE| | |UNAVAILABLE| | |UNAVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Render images | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Render links to uploaded files | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Filter messages (unresolved, own, reacted) | | |AVAILABLE| | |AVAILABLE| | |UNAVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Sort messages (ascending, descending) | | |AVAILABLE| | |NOT PLANNED| | |UNAVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Search for messages in chat | | |UNAVAILABLE| | |UNAVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Search for messages across all chats | | |AVAILABLE| | |UNAVAILABLE| | |UNAVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Open Profile info by clicking profile picture | | |PLANNED| | |AVAILABLE| | |WIP| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Start a conversation from Profile | | |WIP| | |AVAILABLE| | |PLANNED| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| **Link/Attachment Handling** | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | Open lecture, exercise, chat links correctly in | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | +| | the appropriate view | | | | | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Open sent images full-screen | | |AVAILABLE| | |AVAILABLE| | |PLANNED| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Download sent images | | |AVAILABLE| | |PLANNED| | |UNAVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| View and download attachments | | |AVAILABLE| | |PLANNED| | |PLANNED| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| **Conversation Management** | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Search for chats | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Filter chats (all, unread, favorites) | | |UNAVAILABLE| | |AVAILABLE| | |PLANNED| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Mark unread chats | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Mute, hide, favorite chat | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Edit Chat information (name, topic, description) | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Archive Chat | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Delete Chat | | |AVAILABLE| | |UNAVAILABLE| | |PLANNED| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| View Members | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Search Members | | |AVAILABLE| | |UNAVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | Filter Members (All Members, Instructors, | | |AVAILABLE| | |UNAVAILABLE| | |UNAVAILABLE| | +| | Tutors, Students, Moderators) | | | | | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Add Members to existing chat | | Group: members of group | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | +| | | Channel: at least instructor | | | | +| | | or moderator | | | | +| | | DM: not possible | | | | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | Filter Members while adding (Students, Tutors, | | |AVAILABLE| | |UNAVAILABLE| | |AVAILABLE| | +| | Instructors) | | | | | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | Add whole groups (All Students, All Tutors, All | | |AVAILABLE| | |PLANNED| | |UNAVAILABLE| | +| | Instructors) | | | | | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | Grant moderator roles in channels / revoke | Moderators only | |AVAILABLE| | |UNAVAILABLE| | |AVAILABLE| | +| | moderation roles | | | | | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Create direct chat | Everyone | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | Create channel (public/private, | At least teaching assistant | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | +| | announcement/unrestricted) | | | | | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | Update channel information (name, topic, | Moderators | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | +| | description) | | | | | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Create group chat | Everyone | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Remove users from group chat | Members of group chat | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Browse channels | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | Show info in chat overview | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | +| | (created by, created on) | | | | | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Leave chat | For groups only | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Delete channel | | Creators with moderation | |AVAILABLE| | |UNAVAILABLE| | |UNAVAILABLE| | +| | | rights and instructors | | | | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Archive channel | Moderators | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| **Notifications** | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| Notification overview for past notifications | | |AVAILABLE| | |AVAILABLE| | |PLANNED| | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ +| | Notification settings (unsubscribe/subscribe | | |AVAILABLE| | |AVAILABLE| | |AVAILABLE| | +| | to various notification types) | | | | | ++------------------------------------------------------+--------------------------------------+--------------------+---------------------+---------------------+ + +.. note:: + - Leave chat option is available on the web app for groups only, on iOS for groups and non course-wide channels, and on Android for channels, groups, and DMs. + - Creating a group chat on iOS and Android can be achieved via the 'Create Chat' option. It becomes a group when more than one user is added. + - Downloading sent images in the chat is only available through the browser option on the web app. + Features for Users ------------------ diff --git a/docs/user/mobile-applications.rst b/docs/user/mobile-applications.rst index 1182384974fe..64485cb2e2fd 100644 --- a/docs/user/mobile-applications.rst +++ b/docs/user/mobile-applications.rst @@ -10,7 +10,7 @@ Mobile Applications Overview -------- -Artemis supports native mobile applications available for both `Android `_ and `iOS `_. We designed them to be applicable in lecture usage. Users can, for example, participate in quizzes and write questions. Furthermore, they can communicate with each other. +Artemis supports native mobile applications available for both `Android `_ and `iOS `_. We designed them to be applicable in lecture usage. Users can, for example, participate in quizzes and write questions. Furthermore, they can communicate with each other (available communication features on iOS and Android can be checked using :ref:`this list `). Both apps use native user interface components and are adapted to their associated operating system. Therefore, they can differ in their usage. From e13f336d335f8ec44f5a9761276ffb8e9411f7e4 Mon Sep 17 00:00:00 2001 From: Tim Cremer <65229601+cremertim@users.noreply.github.com> Date: Fri, 20 Dec 2024 16:24:11 +0100 Subject: [PATCH 07/40] Communication: Allow users to mark all channels as read (#9994) --- .../conversation/ConversationRepository.java | 8 +++++ .../service/conversation/ChannelService.java | 10 ++++++ .../conversation/ConversationService.java | 24 ++++++++++++++ .../web/conversation/ChannelResource.java | 18 +++++++++++ .../course-conversations.component.html | 1 + .../course-conversations.component.ts | 13 ++++++++ .../conversations/conversation.service.ts | 4 +++ .../metis/metis-conversation.service.ts | 13 ++++++++ .../app/shared/sidebar/sidebar.component.html | 4 +++ .../app/shared/sidebar/sidebar.component.ts | 8 ++++- .../webapp/i18n/de/student-dashboard.json | 3 +- .../webapp/i18n/en/student-dashboard.json | 3 +- .../communication/ChannelIntegrationTest.java | 32 +++++++++++++++++++ .../course-conversations.component.spec.ts | 8 +++++ .../metis-conversation.service.spec.ts | 6 ++++ 15 files changed, 152 insertions(+), 3 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/ConversationRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/ConversationRepository.java index c0c7303336c1..df3782a95a22 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/ConversationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/conversation/ConversationRepository.java @@ -89,4 +89,12 @@ SELECT COUNT(p.id) > 0 ) """) boolean userHasUnreadMessageInCourse(@Param("courseId") Long courseId, @Param("userId") Long userId); + + /** + * Retrieves a list of conversations for the given course + * + * @param courseId the course id + * @return a list of conversations for the given course + */ + List findAllByCourseId(Long courseId); } 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 791847b75670..ec61f3a8fe42 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 @@ -450,4 +450,14 @@ public Channel createFeedbackChannel(Course course, Long exerciseId, ChannelDTO return createdChannel; } + + /** + * Marks all channels of a course as read for the requesting user. + * + * @param course the course for which all channels should be marked as read. + * @param requestingUser the user requesting the marking of all channels as read. + */ + public void markAllChannelsOfCourseAsRead(Course course, User requestingUser) { + conversationService.markAllConversationOfAUserAsRead(course.getId(), requestingUser); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ConversationService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ConversationService.java index 1a981ee84f99..56afa4a5c497 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ConversationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/conversation/ConversationService.java @@ -444,6 +444,30 @@ public void setIsMuted(Long conversationId, User requestingUser, boolean isMuted conversationParticipantRepository.save(conversationParticipant); } + /** + * Mark all conversation of a user as read + * + * @param courseId the id of the course + * @param requestingUser the user that wants to mark the conversation as read + */ + public void markAllConversationOfAUserAsRead(Long courseId, User requestingUser) { + List conversations = conversationRepository.findAllByCourseId(courseId); + ZonedDateTime now = ZonedDateTime.now(); + List participants = new ArrayList<>(); + for (Conversation conversation : conversations) { + boolean userCanBePartOfConversation = conversationParticipantRepository + .findConversationParticipantByConversationIdAndUserId(conversation.getId(), requestingUser.getId()).isPresent() + || (conversation instanceof Channel channel && channel.getIsCourseWide()); + if (userCanBePartOfConversation) { + ConversationParticipant conversationParticipant = getOrCreateConversationParticipant(conversation.getId(), requestingUser); + conversationParticipant.setLastRead(now); + conversationParticipant.setUnreadMessagesCount(0L); + participants.add(conversationParticipant); + } + conversationParticipantRepository.saveAll(participants); + } + } + /** * The user can select one of these roles to filter the conversation members by role */ 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 cbb59c4b7e46..c9d2f0423380 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 @@ -496,6 +496,24 @@ public ResponseEntity createFeedbackChannel(@PathVariable Long cours return ResponseEntity.created(new URI("/api/channels/" + createdChannel.getId())).body(conversationDTOService.convertChannelToDTO(requestingUser, createdChannel)); } + /** + * PUT /api/courses/:courseId/channels/mark-as-read: Marks all channels of a course as read for the current user. + * + * @param courseId the id of the course. + * @return ResponseEntity with status 200 (Ok). + */ + @PutMapping("{courseId}/channels/mark-as-read") + @EnforceAtLeastStudent + public ResponseEntity markAllChannelsOfCourseAsRead(@PathVariable Long courseId) { + log.debug("REST request to mark all channels of course {} as read", courseId); + var requestingUser = userRepository.getUserWithGroupsAndAuthorities(); + var course = courseRepository.findByIdElseThrow(courseId); + checkCommunicationEnabledElseThrow(course); + authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, requestingUser); + channelService.markAllChannelsOfCourseAsRead(course, requestingUser); + return ResponseEntity.ok().build(); + } + private void checkEntityIdMatchesPathIds(Channel channel, Optional courseId, Optional conversationId) { courseId.ifPresent(courseIdValue -> { if (!channel.getCourse().getId().equals(courseIdValue)) { diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html index 6b2bac7d8ada..612880e9c6cd 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html @@ -30,6 +30,7 @@ [courseId]="course.id" [sidebarData]="sidebarData" (onCreateChannelPressed)="openCreateChannelDialog()" + (onMarkAllChannelsAsRead)="markAllChannelAsRead()" (onBrowsePressed)="openChannelOverviewDialog()" (onDirectChatPressed)="openCreateOneToOneChatDialog()" (onGroupChatPressed)="openCreateGroupChatDialog()" diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts index bdc17d480a70..94cfb4929066 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts @@ -521,6 +521,19 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { }); } + markAllChannelAsRead() { + this.metisConversationService.markAllChannelsAsRead(this.course).subscribe({ + complete: () => { + this.metisConversationService.forceRefresh().subscribe({ + complete: () => { + this.prepareSidebarData(); + this.closeSidebarOnMobile(); + }, + }); + }, + }); + } + openChannelOverviewDialog() { const subType = null; const modalRef: NgbModalRef = this.modalService.open(ChannelsOverviewDialogComponent, defaultFirstLayerDialogOptions); diff --git a/src/main/webapp/app/shared/metis/conversations/conversation.service.ts b/src/main/webapp/app/shared/metis/conversations/conversation.service.ts index 07d53e592613..28cc8e95267d 100644 --- a/src/main/webapp/app/shared/metis/conversations/conversation.service.ts +++ b/src/main/webapp/app/shared/metis/conversations/conversation.service.ts @@ -182,4 +182,8 @@ export class ConversationService { params = params.set('page', String(page)); return params.set('size', String(size)); }; + + markAllChannelsAsRead(courseId: number) { + return this.http.put(`${this.resourceUrl}${courseId}/channels/mark-as-read`, { observe: 'response' }); + } } diff --git a/src/main/webapp/app/shared/metis/metis-conversation.service.ts b/src/main/webapp/app/shared/metis/metis-conversation.service.ts index 216d4d49afdb..5062ae79072a 100644 --- a/src/main/webapp/app/shared/metis/metis-conversation.service.ts +++ b/src/main/webapp/app/shared/metis/metis-conversation.service.ts @@ -472,4 +472,17 @@ export class MetisConversationService implements OnDestroy { static getLinkForConversation(courseId: number): RouteComponents { return ['/courses', courseId, 'communication']; } + + markAllChannelsAsRead(course: Course | undefined) { + if (!course?.id) { + return of(); + } + + return this.conversationService.markAllChannelsAsRead(course.id).pipe( + catchError((errorResponse: HttpErrorResponse) => { + onError(this.alertService, errorResponse); + return of(); + }), + ); + } } diff --git a/src/main/webapp/app/shared/sidebar/sidebar.component.html b/src/main/webapp/app/shared/sidebar/sidebar.component.html index 5b812f79d179..64b4517cd43a 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar.component.html +++ b/src/main/webapp/app/shared/sidebar/sidebar.component.html @@ -45,6 +45,10 @@ + } diff --git a/src/main/webapp/app/shared/sidebar/sidebar.component.ts b/src/main/webapp/app/shared/sidebar/sidebar.component.ts index f3ea292820bb..839f1583f930 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar.component.ts +++ b/src/main/webapp/app/shared/sidebar/sidebar.component.ts @@ -1,5 +1,5 @@ import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, effect, input, output } from '@angular/core'; -import { faFilter, faFilterCircleXmark, faHashtag, faPeopleGroup, faPlusCircle, faSearch, faUser } from '@fortawesome/free-solid-svg-icons'; +import { faCheckDouble, faFilter, faFilterCircleXmark, faHashtag, faPeopleGroup, faPlusCircle, faSearch, faUser } from '@fortawesome/free-solid-svg-icons'; import { ActivatedRoute, Params } from '@angular/router'; import { Subscription, distinctUntilChanged } from 'rxjs'; import { ProfileService } from '../layouts/profiles/profile.service'; @@ -28,6 +28,7 @@ export class SidebarComponent implements OnDestroy, OnChanges, OnInit { onGroupChatPressed = output(); onBrowsePressed = output(); onCreateChannelPressed = output(); + onMarkAllChannelsAsRead = output(); @Input() searchFieldEnabled: boolean = true; @Input() sidebarData: SidebarData; @Input() courseId?: number; @@ -61,6 +62,7 @@ export class SidebarComponent implements OnDestroy, OnChanges, OnInit { readonly faPlusCircle = faPlusCircle; readonly faSearch = faSearch; readonly faHashtag = faHashtag; + readonly faCheckDouble = faCheckDouble; sidebarDataBeforeFiltering: SidebarData; @@ -195,4 +197,8 @@ export class SidebarComponent implements OnDestroy, OnChanges, OnInit { achievablePoints: scoreAndPointsFilterOptions?.achievablePoints, }; } + + markAllMessagesAsChecked() { + this.onMarkAllChannelsAsRead.emit(); + } } diff --git a/src/main/webapp/i18n/de/student-dashboard.json b/src/main/webapp/i18n/de/student-dashboard.json index 642af5f892fc..1efa807cf985 100644 --- a/src/main/webapp/i18n/de/student-dashboard.json +++ b/src/main/webapp/i18n/de/student-dashboard.json @@ -82,7 +82,8 @@ "createDirectChat": "Direkt-Chat erstellen", "groupChats": "Gruppenchats", "directMessages": "Direktnachrichten", - "filterConversationPlaceholder": "Konversationen filtern" + "filterConversationPlaceholder": "Konversationen filtern", + "setChannelAsRead": "Alle Kanäle als gelesen markieren" }, "menu": { "exercises": "Aufgaben", diff --git a/src/main/webapp/i18n/en/student-dashboard.json b/src/main/webapp/i18n/en/student-dashboard.json index eb79ff327373..a1be35bda0a4 100644 --- a/src/main/webapp/i18n/en/student-dashboard.json +++ b/src/main/webapp/i18n/en/student-dashboard.json @@ -82,7 +82,8 @@ "createDirectChat": "Create direct chat", "groupChats": "Group Chats", "directMessages": "Direct Messages", - "filterConversationPlaceholder": "Filter conversations" + "filterConversationPlaceholder": "Filter conversations", + "setChannelAsRead": "Mark all channels as read" }, "menu": { "exercises": "Exercises", 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 e737f2be49b2..18fb5eb34cff 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 @@ -7,6 +7,7 @@ import java.util.Arrays; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -21,11 +22,13 @@ import org.springframework.security.test.context.support.WithMockUser; import org.springframework.util.LinkedMultiValueMap; +import de.tum.cit.aet.artemis.communication.domain.ConversationParticipant; 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.service.conversation.ChannelService; import de.tum.cit.aet.artemis.communication.service.conversation.ConversationService; import de.tum.cit.aet.artemis.communication.util.ConversationUtilService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -75,6 +78,9 @@ class ChannelIntegrationTest extends AbstractConversationTest { @Autowired private ProgrammingExerciseUtilService programmingExerciseUtilService; + @Autowired + private ChannelService channelService; + @BeforeEach @Override void setupTestScenario() throws Exception { @@ -973,6 +979,32 @@ void createFeedbackChannel_asInstructor_shouldCreateChannel() { assertThat(response.getDescription()).isEqualTo("Discussion channel for feedback"); } + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void markAllChannelsAsRead() throws Exception { + // ensure there exist atleast two channel with unread messages in the course + ChannelDTO newChannel1 = createChannel(true, "channel1"); + ChannelDTO newChannel2 = createChannel(true, "channel2"); + List channels = channelRepository.findChannelsByCourseId(exampleCourseId); + channels.forEach(channel -> { + addUsersToConversation(channel.getId(), "instructor1"); + conversationParticipantRepository.findConversationParticipantsByConversationId(channel.getId()).forEach(conversationParticipant -> { + conversationParticipant.setUnreadMessagesCount(1L); + conversationParticipantRepository.save(conversationParticipant); + }); + }); + + User requestingUser = userTestRepository.getUser(); + request.put("/api/courses/" + exampleCourseId + "/channels/mark-as-read", null, HttpStatus.OK); + List updatedChannels = channelRepository.findChannelsByCourseId(exampleCourseId); + updatedChannels.forEach(channel -> { + Optional conversationParticipant = conversationParticipantRepository.findConversationParticipantByConversationIdAndUserId(channel.getId(), + requestingUser.getId()); + assertThat(conversationParticipant.get().getUnreadMessagesCount()).isEqualTo(0L); + }); + + } + 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/overview/course-conversations/course-conversations.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/course-conversations.component.spec.ts index 825d153b7af7..7add13f9c64f 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/course-conversations.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/course-conversations.component.spec.ts @@ -580,6 +580,14 @@ examples.forEach((activeConversation) => { }); }); + it('should mark all channels as read', () => { + const markAllChannelsAsRead = jest.spyOn(metisConversationService, 'markAllChannelsAsRead').mockReturnValue(of()); + const forceRefresh = jest.spyOn(metisConversationService, 'forceRefresh'); + component.markAllChannelAsRead(); + expect(markAllChannelsAsRead).toHaveBeenCalledOnce(); + expect(forceRefresh).toHaveBeenCalledTimes(2); + }); + describe('conversation selection', () => { it('should handle numeric conversationId', () => { component.onConversationSelected(123); diff --git a/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts index b397fe2951d3..1eb01c92d9cb 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/services/metis-conversation.service.spec.ts @@ -432,4 +432,10 @@ describe('MetisConversationService', () => { metisConversationService.markAsRead(2); expect(metisConversationService['conversationsOfUser'][1].unreadMessagesCount).toBe(0); }); + + it('should call refresh after marking all channels as read', () => { + const markAllChannelAsReadSpy = jest.spyOn(conversationService, 'markAllChannelsAsRead').mockReturnValue(of()); + metisConversationService.markAllChannelsAsRead(course); + expect(markAllChannelAsReadSpy).toHaveBeenCalledOnce(); + }); }); From 88c9be66449681f70e5ccf9b906c35b2702d437f Mon Sep 17 00:00:00 2001 From: Marcel Gaupp Date: Fri, 20 Dec 2024 16:27:14 +0100 Subject: [PATCH 08/40] Programming exercises: Fix inconsistencies between diff viewer and diff line stats (#9984) --- .../service/CommitHistoryService.java | 2 +- ...ogrammingExerciseGitDiffReportService.java | 22 ++-- .../web/GitDiffReportParserService.java | 111 ++++++++++++------ .../artemis/{ => atlas}/UnionFindTest.java | 2 +- ...gExerciseGitDiffReportIntegrationTest.java | 40 +++++-- ...mmingExerciseGitDiffReportServiceTest.java | 9 ++ 6 files changed, 130 insertions(+), 56 deletions(-) rename src/test/java/de/tum/cit/aet/artemis/{ => atlas}/UnionFindTest.java (98%) diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/CommitHistoryService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/CommitHistoryService.java index 3d8beec05006..9ee663c55c96 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/CommitHistoryService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/CommitHistoryService.java @@ -107,7 +107,7 @@ private ProgrammingExerciseGitDiffReport createReport(Repository repository, Rev diffs.append(out.toString(StandardCharsets.UTF_8)); } - var programmingExerciseGitDiffEntries = gitDiffReportParserService.extractDiffEntries(diffs.toString(), false); + var programmingExerciseGitDiffEntries = gitDiffReportParserService.extractDiffEntries(diffs.toString(), false, false); var report = new ProgrammingExerciseGitDiffReport(); for (ProgrammingExerciseGitDiffEntry gitDiffEntry : programmingExerciseGitDiffEntries) { gitDiffEntry.setGitDiffReport(report); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseGitDiffReportService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseGitDiffReportService.java index 7ace2505cf1c..6950e1216df5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseGitDiffReportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/hestia/ProgrammingExerciseGitDiffReportService.java @@ -109,7 +109,7 @@ public ProgrammingExerciseGitDiffReport updateReport(ProgrammingExercise program var templateHash = templateSubmission.getCommitHash(); var solutionHash = solutionSubmission.getCommitHash(); - var existingReport = this.getReportOfExercise(programmingExercise); + var existingReport = getReportOfExercise(programmingExercise); if (existingReport != null && canUseExistingReport(existingReport, templateHash, solutionHash)) { return existingReport; } @@ -164,7 +164,7 @@ else if (reports.size() == 1) { * @return The report or null if none can be generated */ public ProgrammingExerciseGitDiffReport getOrCreateReportOfExercise(ProgrammingExercise programmingExercise) { - var report = this.getReportOfExercise(programmingExercise); + var report = getReportOfExercise(programmingExercise); if (report == null) { return updateReport(programmingExercise); } @@ -215,7 +215,7 @@ public int calculateNumberOfDiffLinesBetweenRepos(VcsRepositoryUri urlRepoA, Pat try (var diffOutputStream = new ByteArrayOutputStream(); var git = Git.wrap(repoB)) { git.diff().setOldTree(treeParserRepoB).setNewTree(treeParserRepoA).setOutputStream(diffOutputStream).call(); var diff = diffOutputStream.toString(); - return gitDiffReportParserService.extractDiffEntries(diff, true).stream().mapToInt(ProgrammingExerciseGitDiffEntry::getLineCount).sum(); + return gitDiffReportParserService.extractDiffEntries(diff, true, false).stream().mapToInt(ProgrammingExerciseGitDiffEntry::getLineCount).sum(); } catch (IOException | GitAPIException e) { log.error("Error calculating number of diff lines between repositories: urlRepoA={}, urlRepoB={}.", urlRepoA, urlRepoB, e); @@ -234,6 +234,7 @@ public int calculateNumberOfDiffLinesBetweenRepos(VcsRepositoryUri urlRepoA, Pat */ private ProgrammingExerciseGitDiffReport generateReport(TemplateProgrammingExerciseParticipation templateParticipation, SolutionProgrammingExerciseParticipation solutionParticipation) throws GitAPIException, IOException { + // TODO: in case of LocalVC, we should calculate the diff in the bare origin repository Repository templateRepo = prepareTemplateRepository(templateParticipation); var solutionRepo = gitService.getOrCheckoutRepository(solutionParticipation.getVcsRepositoryUri(), true); gitService.resetToOriginHead(solutionRepo); @@ -306,19 +307,20 @@ private ProgrammingExerciseGitDiffReport parseFilesAndCreateReport(Repository re * It parses all files of the repositories in their directories on the file system and creates a report containing the changes. * Both repositories have to be checked out at the commit that should be compared and be in different directories * - * @param repo1 The first repository - * @param oldTreeParser The tree parser for the first repository - * @param newTreeParser The tree parser for the second repository + * @param firstRepo The first repository + * @param firstRepoTreeParser The tree parser for the first repository + * @param secondRepoTreeParser The tree parser for the second repository * @return The report with the changes between the two repositories at their checked out state * @throws IOException If an error occurs while accessing the file system * @throws GitAPIException If an error occurs while accessing the git repository */ @NotNull - private ProgrammingExerciseGitDiffReport createReport(Repository repo1, FileTreeIterator oldTreeParser, FileTreeIterator newTreeParser) throws IOException, GitAPIException { - try (ByteArrayOutputStream diffOutputStream = new ByteArrayOutputStream(); Git git = Git.wrap(repo1)) { - git.diff().setOldTree(oldTreeParser).setNewTree(newTreeParser).setOutputStream(diffOutputStream).call(); + private ProgrammingExerciseGitDiffReport createReport(Repository firstRepo, FileTreeIterator firstRepoTreeParser, FileTreeIterator secondRepoTreeParser) + throws IOException, GitAPIException { + try (ByteArrayOutputStream diffOutputStream = new ByteArrayOutputStream(); Git git = Git.wrap(firstRepo)) { + git.diff().setOldTree(firstRepoTreeParser).setNewTree(secondRepoTreeParser).setOutputStream(diffOutputStream).call(); var diff = diffOutputStream.toString(); - var programmingExerciseGitDiffEntries = gitDiffReportParserService.extractDiffEntries(diff, false); + var programmingExerciseGitDiffEntries = gitDiffReportParserService.extractDiffEntries(diff, false, true); var report = new ProgrammingExerciseGitDiffReport(); for (ProgrammingExerciseGitDiffEntry gitDiffEntry : programmingExerciseGitDiffEntries) { gitDiffEntry.setGitDiffReport(report); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/GitDiffReportParserService.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/GitDiffReportParserService.java index a12dff5575d6..37042b8cdb06 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/GitDiffReportParserService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/GitDiffReportParserService.java @@ -23,16 +23,17 @@ public class GitDiffReportParserService { private static final String PREFIX_RENAME_TO = "rename to "; - private final Pattern gitDiffLinePattern = Pattern.compile("@@ -(?\\d+)(,(?\\d+))? \\+(?\\d+)(,(?\\d+))? @@"); + private final Pattern gitDiffLinePattern = Pattern.compile("@@ -(?\\d+)(,(?\\d+))? \\+(?\\d+)(,(?\\d+))? @@.*"); /** * Extracts the ProgrammingExerciseGitDiffEntry from the raw git-diff output * * @param diff The raw git-diff output * @param useAbsoluteLineCount Whether to use absolute line count or previous line count + * @param ignoreWhitespace Whether to ignore entries where only leading and trailing whitespace differ * @return The extracted ProgrammingExerciseGitDiffEntries */ - public List extractDiffEntries(String diff, boolean useAbsoluteLineCount) { + public List extractDiffEntries(String diff, boolean useAbsoluteLineCount, boolean ignoreWhitespace) { var lines = diff.split("\n"); var parserState = new ParserState(); Map renamedFilePaths = new HashMap<>(); @@ -44,8 +45,7 @@ public List extractDiffEntries(String diff, boo continue; } - // Files may be renamed without changes, in which case the lineMatcher will never match the entry - // We store this information separately so it is not lost + // Check for renamed files if (line.startsWith(PREFIX_RENAME_FROM) && i + 1 < lines.length) { var nextLine = lines[i + 1]; if (nextLine.startsWith(PREFIX_RENAME_TO)) { @@ -57,34 +57,35 @@ public List extractDiffEntries(String diff, boo var lineMatcher = gitDiffLinePattern.matcher(line); if (lineMatcher.matches()) { - handleNewDiffBlock(lines, i, parserState, lineMatcher); + handleNewDiffBlock(lines, i, parserState, lineMatcher, ignoreWhitespace); } - else if (!parserState.deactivateCodeReading) { + else if (!parserState.deactivateCodeReading && !line.isEmpty()) { switch (line.charAt(0)) { - case '+' -> handleAddition(parserState); - case '-' -> handleRemoval(parserState, useAbsoluteLineCount); - case ' ' -> handleUnchanged(parserState); + case '+' -> handleAddition(parserState, line); + case '-' -> handleRemoval(parserState, useAbsoluteLineCount, line); + case ' ' -> handleUnchanged(parserState, ignoreWhitespace); default -> parserState.deactivateCodeReading = true; } } } - if (!parserState.currentEntry.isEmpty()) { - parserState.entries.add(parserState.currentEntry); - } - // Add an empty diff entry for renamed files without changes + + // Check the last entry + finalizeEntry(parserState, ignoreWhitespace); + + // Add empty entries for renamed files without changes for (var entry : renamedFilePaths.entrySet()) { var diffEntry = new ProgrammingExerciseGitDiffEntry(); diffEntry.setFilePath(entry.getValue()); diffEntry.setPreviousFilePath(entry.getKey()); parserState.entries.add(diffEntry); } + return parserState.entries; } - private void handleNewDiffBlock(String[] lines, int currentLine, ParserState parserState, Matcher lineMatcher) { - if (!parserState.currentEntry.isEmpty()) { - parserState.entries.add(parserState.currentEntry); - } + private void handleNewDiffBlock(String[] lines, int currentLine, ParserState parserState, Matcher lineMatcher, boolean ignoreWhitespace) { + finalizeEntry(parserState, ignoreWhitespace); + // Start of a new file var newFilePath = getFilePath(lines, currentLine); var newPreviousFilePath = getPreviousFilePath(lines, currentLine); @@ -92,40 +93,45 @@ private void handleNewDiffBlock(String[] lines, int currentLine, ParserState par parserState.currentFilePath = newFilePath; parserState.currentPreviousFilePath = newPreviousFilePath; } + parserState.currentEntry = new ProgrammingExerciseGitDiffEntry(); parserState.currentEntry.setFilePath(parserState.currentFilePath); parserState.currentEntry.setPreviousFilePath(parserState.currentPreviousFilePath); parserState.currentLineCount = Integer.parseInt(lineMatcher.group("newLine")); parserState.currentPreviousLineCount = Integer.parseInt(lineMatcher.group("previousLine")); parserState.deactivateCodeReading = false; + parserState.addedLines.clear(); + parserState.removedLines.clear(); } - private void handleUnchanged(ParserState parserState) { - var entry = parserState.currentEntry; - if (!entry.isEmpty()) { - parserState.entries.add(entry); - } - entry = new ProgrammingExerciseGitDiffEntry(); - entry.setFilePath(parserState.currentFilePath); - entry.setPreviousFilePath(parserState.currentPreviousFilePath); + private void handleUnchanged(ParserState parserState, boolean ignoreWhitespace) { + finalizeEntry(parserState, ignoreWhitespace); + parserState.currentEntry = new ProgrammingExerciseGitDiffEntry(); + parserState.currentEntry.setFilePath(parserState.currentFilePath); + parserState.currentEntry.setPreviousFilePath(parserState.currentPreviousFilePath); - parserState.currentEntry = entry; parserState.lastLineRemoveOperation = false; parserState.currentLineCount++; parserState.currentPreviousLineCount++; + parserState.addedLines.clear(); + parserState.removedLines.clear(); } - private void handleRemoval(ParserState parserState, boolean useAbsoluteLineCount) { + private void handleRemoval(ParserState parserState, boolean useAbsoluteLineCount, String line) { var entry = parserState.currentEntry; if (!parserState.lastLineRemoveOperation && !entry.isEmpty()) { - parserState.entries.add(entry); - entry = new ProgrammingExerciseGitDiffEntry(); - entry.setFilePath(parserState.currentFilePath); - entry.setPreviousFilePath(parserState.currentPreviousFilePath); + finalizeEntry(parserState, false); + parserState.currentEntry = new ProgrammingExerciseGitDiffEntry(); + parserState.currentEntry.setFilePath(parserState.currentFilePath); + parserState.currentEntry.setPreviousFilePath(parserState.currentPreviousFilePath); } - if (entry.getPreviousLineCount() == null) { - entry.setPreviousLineCount(0); - entry.setPreviousStartLine(parserState.currentPreviousLineCount); + + // Store removed line + parserState.removedLines.add(line.substring(1)); + + if (parserState.currentEntry.getPreviousLineCount() == null) { + parserState.currentEntry.setPreviousLineCount(0); + parserState.currentEntry.setPreviousStartLine(parserState.currentPreviousLineCount); } if (useAbsoluteLineCount) { if (parserState.currentEntry.getLineCount() == null) { @@ -135,15 +141,17 @@ private void handleRemoval(ParserState parserState, boolean useAbsoluteLineCount parserState.currentEntry.setLineCount(parserState.currentEntry.getLineCount() + 1); } else { - entry.setPreviousLineCount(entry.getPreviousLineCount() + 1); + parserState.currentEntry.setPreviousLineCount(parserState.currentEntry.getPreviousLineCount() + 1); } - parserState.currentEntry = entry; parserState.lastLineRemoveOperation = true; parserState.currentPreviousLineCount++; } - private void handleAddition(ParserState parserState) { + private void handleAddition(ParserState parserState, String line) { + // Store added line + parserState.addedLines.add(line.substring(1)); + if (parserState.currentEntry.getLineCount() == null) { parserState.currentEntry.setLineCount(0); parserState.currentEntry.setStartLine(parserState.currentLineCount); @@ -154,6 +162,29 @@ private void handleAddition(ParserState parserState) { parserState.currentLineCount++; } + private void finalizeEntry(ParserState parserState, boolean ignoreWhitespace) { + if (!parserState.currentEntry.isEmpty()) { + if (!ignoreWhitespace || !isWhitespaceOnlyChange(parserState.addedLines, parserState.removedLines)) { + parserState.entries.add(parserState.currentEntry); + } + } + } + + private boolean isWhitespaceOnlyChange(List addedLines, List removedLines) { + if (addedLines.size() != removedLines.size()) { + return false; // Different number of lines changed, definitely not whitespace only + } + + for (int i = 0; i < addedLines.size(); i++) { + String added = addedLines.get(i).trim(); + String removed = removedLines.get(i).trim(); + if (!added.equals(removed)) { + return false; + } + } + return true; + } + /** * Extracts the file path from the raw git-diff for a specified diff block * @@ -219,6 +250,10 @@ private static class ParserState { private int currentPreviousLineCount; + private final List addedLines; + + private final List removedLines; + public ParserState() { entries = new ArrayList<>(); currentEntry = new ProgrammingExerciseGitDiffEntry(); @@ -226,6 +261,8 @@ public ParserState() { lastLineRemoveOperation = false; currentLineCount = 0; currentPreviousLineCount = 0; + addedLines = new ArrayList<>(); + removedLines = new ArrayList<>(); } } } diff --git a/src/test/java/de/tum/cit/aet/artemis/UnionFindTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/UnionFindTest.java similarity index 98% rename from src/test/java/de/tum/cit/aet/artemis/UnionFindTest.java rename to src/test/java/de/tum/cit/aet/artemis/atlas/UnionFindTest.java index 4d6b0b8ebbc6..5709a598a27c 100644 --- a/src/test/java/de/tum/cit/aet/artemis/UnionFindTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/UnionFindTest.java @@ -1,4 +1,4 @@ -package de.tum.cit.aet.artemis; +package de.tum.cit.aet.artemis.atlas; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportIntegrationTest.java index df54e4dc10f5..b896af7ac582 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportIntegrationTest.java @@ -38,7 +38,7 @@ class ProgrammingExerciseGitDiffReportIntegrationTest extends AbstractProgrammin private ProgrammingExercise exercise; @BeforeEach - void initTestCase() throws Exception { + void initTestCase() { Course course = courseUtilService.addEmptyCourse(); userUtilService.addUsers(TEST_PREFIX, 1, 1, 1, 1); exercise = ProgrammingExerciseFactory.generateProgrammingExercise(ZonedDateTime.now().minusDays(1), ZonedDateTime.now().plusDays(7), course); @@ -70,7 +70,9 @@ void getGitDiffAsATutor() throws Exception { exercise = hestiaUtilTestService.setupTemplate(FILE_NAME, "TEST", exercise, templateRepo); exercise = hestiaUtilTestService.setupSolution(FILE_NAME, "TEST", exercise, solutionRepo); reportService.updateReport(exercise); - request.get("/api/programming-exercises/" + exercise.getId() + "/diff-report", HttpStatus.OK, ProgrammingExerciseGitDiffReport.class); + var report = request.get("/api/programming-exercises/" + exercise.getId() + "/diff-report", HttpStatus.OK, ProgrammingExerciseGitDiffReport.class); + assertThat(report).isNotNull(); + assertThat(report.getEntries()).isNull(); } @Test @@ -79,7 +81,9 @@ void getGitDiffAsAnEditor() throws Exception { exercise = hestiaUtilTestService.setupTemplate(FILE_NAME, "TEST", exercise, templateRepo); exercise = hestiaUtilTestService.setupSolution(FILE_NAME, "TEST", exercise, solutionRepo); reportService.updateReport(exercise); - request.get("/api/programming-exercises/" + exercise.getId() + "/diff-report", HttpStatus.OK, ProgrammingExerciseGitDiffReport.class); + var report = request.get("/api/programming-exercises/" + exercise.getId() + "/diff-report", HttpStatus.OK, ProgrammingExerciseGitDiffReport.class); + assertThat(report).isNotNull(); + assertThat(report.getEntries()).isNull(); } @Test @@ -88,7 +92,9 @@ void getGitDiffAsAnInstructor() throws Exception { exercise = hestiaUtilTestService.setupTemplate(FILE_NAME, "TEST", exercise, templateRepo); exercise = hestiaUtilTestService.setupSolution(FILE_NAME, "TEST", exercise, solutionRepo); reportService.updateReport(exercise); - request.get("/api/programming-exercises/" + exercise.getId() + "/diff-report", HttpStatus.OK, ProgrammingExerciseGitDiffReport.class); + var report = request.get("/api/programming-exercises/" + exercise.getId() + "/diff-report", HttpStatus.OK, ProgrammingExerciseGitDiffReport.class); + assertThat(report).isNotNull(); + assertThat(report.getEntries()).isNull(); } @Test @@ -98,8 +104,10 @@ void getGitDiffBetweenTemplateAndSubmission() throws Exception { participationRepo.configureRepos("participationLocalRepo", "participationOriginRepo"); var studentLogin = TEST_PREFIX + "student1"; var submission = hestiaUtilTestService.setupSubmission(FILE_NAME, "TEST", exercise, participationRepo, studentLogin); - request.get("/api/programming-exercises/" + exercise.getId() + "/submissions/" + submission.getId() + "/diff-report-with-template", HttpStatus.OK, + var report = request.get("/api/programming-exercises/" + exercise.getId() + "/submissions/" + submission.getId() + "/diff-report-with-template", HttpStatus.OK, ProgrammingExerciseGitDiffReport.class); + assertThat(report).isNotNull(); + assertThat(report.getEntries()).isNull(); } @Test @@ -121,8 +129,17 @@ void getGitDiffReportForCommits() throws Exception { var studentLogin = TEST_PREFIX + "student1"; var submission = hestiaUtilTestService.setupSubmission(FILE_NAME, "TEST", exercise, participationRepo, studentLogin); var submission2 = hestiaUtilTestService.setupSubmission(FILE_NAME, "TEST2", exercise, participationRepo, studentLogin); - request.get("/api/programming-exercises/" + exercise.getId() + "/commits/" + submission.getCommitHash() + "/diff-report/" + submission2.getCommitHash() + var report = request.get("/api/programming-exercises/" + exercise.getId() + "/commits/" + submission.getCommitHash() + "/diff-report/" + submission2.getCommitHash() + "?participationId=" + submission.getParticipation().getId(), HttpStatus.OK, ProgrammingExerciseGitDiffReport.class); + assertThat(report).isNotNull(); + assertThat(report.getEntries()).hasSize(1); + var entry = report.getEntries().stream().findAny().orElseThrow(); + assertThat(entry.getPreviousFilePath()).isEqualTo(FILE_NAME); + assertThat(entry.getPreviousStartLine()).isEqualTo(1); + assertThat(entry.getPreviousLineCount()).isEqualTo(1); + assertThat(entry.getFilePath()).isEqualTo(FILE_NAME); + assertThat(entry.getStartLine()).isEqualTo(1); + assertThat(entry.getLineCount()).isEqualTo(1); } @Test @@ -179,8 +196,17 @@ void getGitDiffBetweenTwoSubmissions() throws Exception { var studentLogin = TEST_PREFIX + "student1"; var submission = hestiaUtilTestService.setupSubmission(FILE_NAME, "TEST", exercise, participationRepo, studentLogin); var submission2 = hestiaUtilTestService.setupSubmission(FILE_NAME, "TEST2", exercise, participationRepo, studentLogin); - request.get("/api/programming-exercises/" + exercise.getId() + "/submissions/" + submission.getId() + "/diff-report/" + submission2.getId(), HttpStatus.OK, + var report = request.get("/api/programming-exercises/" + exercise.getId() + "/submissions/" + submission.getId() + "/diff-report/" + submission2.getId(), HttpStatus.OK, ProgrammingExerciseGitDiffReport.class); + assertThat(report).isNotNull(); + assertThat(report.getEntries()).hasSize(1); + var entry = report.getEntries().stream().findAny().orElseThrow(); + assertThat(entry.getPreviousFilePath()).isEqualTo(FILE_NAME); + assertThat(entry.getPreviousStartLine()).isEqualTo(1); + assertThat(entry.getPreviousLineCount()).isEqualTo(1); + assertThat(entry.getFilePath()).isEqualTo(FILE_NAME); + assertThat(entry.getStartLine()).isEqualTo(1); + assertThat(entry.getLineCount()).isEqualTo(1); } @Test diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportServiceTest.java index 2516a2f416fc..a79c8880e1fb 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportServiceTest.java @@ -136,6 +136,15 @@ void updateGitDiffDoubleModify() throws Exception { assertThat(entries.get(1).getLineCount()).isEqualTo(1); } + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void gitDiffWhitespace() throws Exception { + exercise = hestiaUtilTestService.setupTemplate(FILE_NAME, " ", exercise, templateRepo); + exercise = hestiaUtilTestService.setupSolution(FILE_NAME, "\t", exercise, solutionRepo); + var report = reportService.updateReport(exercise); + assertThat(report.getEntries()).hasSize(0); + } + @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") void updateGitDiffReuseExisting() throws Exception { From 8fafdeec39d0ba6a352e83dc1fd590e489c3931c Mon Sep 17 00:00:00 2001 From: Aniruddh Zaveri <92953467+az108@users.noreply.github.com> Date: Fri, 20 Dec 2024 16:29:28 +0100 Subject: [PATCH 09/40] Programming exercises: Add group feedback feature to feedback analysis table (#9884) --- .../dto/FeedbackAffectedStudentDTO.java | 2 +- .../dto/FeedbackAnalysisResponseDTO.java | 2 +- .../assessment/dto/FeedbackDetailDTO.java | 10 +- .../assessment/service/ResultService.java | 161 ++++++++++++----- .../assessment/web/ResultResource.java | 71 ++++---- .../dto/FeedbackChannelRequestDTO.java | 4 +- .../service/conversation/ChannelService.java | 15 +- .../web/conversation/ChannelResource.java | 5 +- .../cit/aet/artemis/core/util/PageUtil.java | 5 +- .../StudentParticipationRepository.java | 79 ++++----- ...ack-affected-students-modal.component.html | 70 ++++---- ...dback-affected-students-modal.component.ts | 30 ++-- ...edback-detail-channel-modal.component.html | 5 +- ...feedback-detail-channel-modal.component.ts | 4 +- .../Modal/feedback-modal.component.html | 2 +- .../feedback-analysis.component.html | 95 ++++++---- .../feedback-analysis.component.ts | 30 +++- .../feedback-analysis.service.ts | 40 ++--- .../webapp/i18n/de/programmingExercise.json | 9 +- .../webapp/i18n/en/programmingExercise.json | 9 +- .../ResultServiceIntegrationTest.java | 167 ++++++++---------- .../communication/ChannelIntegrationTest.java | 4 +- .../feedback-analysis.component.spec.ts | 22 ++- .../feedback-analysis.service.spec.ts | 109 ++++++------ ...-affected-students-modal.component.spec.ts | 27 ++- ...ack-detail-channel-modal.component.spec.ts | 8 +- .../modals/feedback-modal.component.spec.ts | 6 +- 27 files changed, 529 insertions(+), 462 deletions(-) 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: *