From 1435bfe24ac16625c448b4bdee66da80ff6aac41 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Sun, 15 Dec 2024 15:34:44 +0100 Subject: [PATCH 01/12] Development: Optimize exam query --- .../artemis/core/service/CourseService.java | 2 +- .../exam/repository/ExamRepository.java | 22 +++++++++---------- .../iris/PyrisEventSystemIntegrationTest.java | 2 +- .../aelous/AeolusTemplateResourceTest.java | 6 ++--- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java index 1b037532e453..350a165409b8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/CourseService.java @@ -350,7 +350,7 @@ public Course findOneWithExercisesAndLecturesAndExamsAndCompetenciesAndTutorialG course.setExercises(exerciseRepository.findByCourseIdWithCategories(course.getId())); course.setExercises(exerciseService.filterExercisesForCourse(course, user)); exerciseService.loadExerciseDetailsIfNecessary(course, user); - course.setExams(examRepository.findByCourseIdsForUser(Set.of(course.getId()), user.getId(), user.getGroups(), ZonedDateTime.now())); + course.setExams(examRepository.findByCourseIdForUser(course.getId(), user.getId(), user.getGroups(), ZonedDateTime.now())); // TODO: in the future, we only want to know if lectures exist, the actual lectures will be loaded when the user navigates into the lecture course.setLectures(lectureService.filterVisibleLecturesWithActiveAttachments(course, course.getLectures(), user)); // NOTE: in this call we only want to know if competencies exist in the course, we will load them when the user navigates into them diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/repository/ExamRepository.java b/src/main/java/de/tum/cit/aet/artemis/exam/repository/ExamRepository.java index 429ce9b91e39..a884ed754e4a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/repository/ExamRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/repository/ExamRepository.java @@ -56,30 +56,30 @@ public interface ExamRepository extends ArtemisJpaRepository { List findByCourseIdWithExerciseGroupsAndExercises(@Param("courseId") long courseId); /** - * Find all exams for multiple courses that are already visible to the user (either registered, at least tutor or the exam is a test exam) + * Find all exams for a given course that are already visible to the user (either registered, at least tutor or the exam is a test exam) * - * @param courseIds set of courseIds that the exams should be retrieved + * @param courseId the course for which the exams should be retrieved * @param userId the id of the user requesting the exams * @param groupNames the groups of the user requesting the exams * @param now the current date, typically ZonedDateTime.now() * @return a set of all visible exams for the user in the provided courses */ @Query(""" - SELECT e + SELECT DISTINCT e FROM Exam e - LEFT JOIN e.examUsers registeredUsers - WHERE e.course.id IN :courseIds + LEFT JOIN e.examUsers eu + LEFT JOIN e.course c + WHERE c.id = :courseId AND e.visibleDate <= :now AND ( - registeredUsers.user.id = :userId - OR e.course.teachingAssistantGroupName IN :groupNames - OR e.course.editorGroupName IN :groupNames - OR e.course.instructorGroupName IN :groupNames + eu.user.id = :userId + OR c.teachingAssistantGroupName IN :groupNames + OR c.editorGroupName IN :groupNames + OR c.instructorGroupName IN :groupNames OR e.testExam = TRUE ) """) - Set findByCourseIdsForUser(@Param("courseIds") Set courseIds, @Param("userId") long userId, @Param("groupNames") Set groupNames, - @Param("now") ZonedDateTime now); + Set findByCourseIdForUser(@Param("courseId") Long courseId, @Param("userId") long userId, @Param("groupNames") Set groupNames, @Param("now") ZonedDateTime now); @Query(""" SELECT new de.tum.cit.aet.artemis.core.dto.CourseContentCount( diff --git a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisEventSystemIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisEventSystemIntegrationTest.java index 62bc019b38a8..95b620850ddd 100644 --- a/src/test/java/de/tum/cit/aet/artemis/iris/PyrisEventSystemIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/iris/PyrisEventSystemIntegrationTest.java @@ -253,7 +253,7 @@ public void handleEvent(IrisExerciseChatSessionService service) { } - @Test() + @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void testShouldNotFireProgressStalledEventWithEventDisabled() { // Find settings for the current exercise diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/aelous/AeolusTemplateResourceTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/aelous/AeolusTemplateResourceTest.java index 8219e383912d..3c82d8858409 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/aelous/AeolusTemplateResourceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/aelous/AeolusTemplateResourceTest.java @@ -56,7 +56,7 @@ void testGetAeolusTemplateFile(String templateKey, Integer expectedScriptActions assertWindfileIsCorrect(windfile, expectedScriptActions); } - @Test() + @Test void testInvalidWindfileDeserialization() { try { String invalidWindfile = """ @@ -82,7 +82,7 @@ void testInvalidWindfileDeserialization() { } } - @Test() + @Test void testValidWindfileDeserializationWithClass() throws JsonProcessingException { String validWindfile = """ { @@ -117,7 +117,7 @@ void testValidWindfileDeserializationWithClass() throws JsonProcessingException assertThat(windfile.getActions().getFirst()).isInstanceOf(ScriptAction.class); } - @Test() + @Test void testValidWindfileWithInvalidAction() { // NOTE: the misspellings are intended String invalidWindfile = """ From 1f0f75b61ee1a2478b6f1acd2dee4fd8cf9212db Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Sun, 15 Dec 2024 18:04:55 +0100 Subject: [PATCH 02/12] Iris: Only load settings on non exam exercise to prevent bad request banners --- .../manage/programming-exercise-detail.component.ts | 2 +- .../app/exercises/text/participate/text-editor.component.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts b/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts index baed01da2383..dd1c4ff747d7 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise-detail.component.ts @@ -213,7 +213,7 @@ export class ProgrammingExerciseDetailComponent implements OnInit, OnDestroy { this.localVCEnabled = profileInfo.activeProfiles.includes(PROFILE_LOCALVC); this.localCIEnabled = profileInfo.activeProfiles.includes(PROFILE_LOCALCI); this.irisEnabled = profileInfo.activeProfiles.includes(PROFILE_IRIS); - if (this.irisEnabled) { + if (this.irisEnabled && !this.isExamExercise) { this.irisSettingsSubscription = this.irisSettingsService.getCombinedCourseSettings(this.courseId).subscribe((settings) => { this.irisChatEnabled = settings?.irisChatSettings?.enabled ?? false; }); diff --git a/src/main/webapp/app/exercises/text/participate/text-editor.component.ts b/src/main/webapp/app/exercises/text/participate/text-editor.component.ts index 81deae339df6..3a6bb245c677 100644 --- a/src/main/webapp/app/exercises/text/participate/text-editor.component.ts +++ b/src/main/webapp/app/exercises/text/participate/text-editor.component.ts @@ -152,7 +152,8 @@ export class TextEditorComponent implements OnInit, OnDestroy, ComponentCanDeact this.updateParticipation(this.participation); }); this.profileService.getProfileInfo().subscribe((profileInfo) => { - if (profileInfo?.activeProfiles?.includes(PROFILE_IRIS)) { + // only load the settings if Iris is available and this is not an exam exercise + if (profileInfo?.activeProfiles?.includes(PROFILE_IRIS) && !this.examMode) { this.route.params.subscribe((params) => { this.irisSettingsService.getCombinedExerciseSettings(params['exerciseId']).subscribe((irisSettings) => { this.irisSettings = irisSettings; From b21a9c42b5882e40d84dbe8953d9d4907f238f78 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Sun, 15 Dec 2024 20:04:04 +0100 Subject: [PATCH 03/12] Exam mode: Do not display working time differences for tests exams --- jest.config.js | 6 +- .../student-exam-detail.component.html | 12 ++- .../student-exam-detail.component.ts | 7 +- .../student-exam-working-time.component.html | 2 +- .../student-exam-working-time.component.ts | 6 +- .../conversation-members.component.ts | 10 +-- .../conversation-settings.component.ts | 88 ++++++++----------- src/main/webapp/i18n/de/exam.json | 5 +- src/main/webapp/i18n/en/exam.json | 3 +- .../conversation-members.component.spec.ts | 3 +- 10 files changed, 71 insertions(+), 71 deletions(-) diff --git a/jest.config.js b/jest.config.js index 7084e7496f93..bf1c69c0bcd7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -105,10 +105,10 @@ module.exports = { coverageThreshold: { global: { // TODO: in the future, the following values should increase to at least 90% - statements: 87.67, + statements: 87.69, branches: 73.79, - functions: 82.27, - lines: 87.72, + functions: 82.28, + lines: 87.74, }, }, coverageReporters: ['clover', 'json', 'lcov', 'text-summary'], diff --git a/src/main/webapp/app/exam/manage/student-exams/student-exam-detail.component.html b/src/main/webapp/app/exam/manage/student-exams/student-exam-detail.component.html index c7dcd480e6e8..7b825a7471e5 100644 --- a/src/main/webapp/app/exam/manage/student-exams/student-exam-detail.component.html +++ b/src/main/webapp/app/exam/manage/student-exams/student-exam-detail.component.html @@ -1,13 +1,17 @@
- @if (!isTestRun) { -

- Student exam ({{ studentExam.exam!.title! }}) + @if (isTestExam) { +

+ Student test exam ({{ studentExam.exam!.title! }})

- } @else { + } @else if (isTestRun) {

Test Run ({{ studentExam.exam!.title! }})

+ } @else { +

+ Student exam ({{ studentExam.exam!.title! }}) +

}
diff --git a/src/main/webapp/app/exam/manage/student-exams/student-exam-detail.component.ts b/src/main/webapp/app/exam/manage/student-exams/student-exam-detail.component.ts index 817b93ffddb9..8d8a79acccfb 100644 --- a/src/main/webapp/app/exam/manage/student-exams/student-exam-detail.component.ts +++ b/src/main/webapp/app/exam/manage/student-exams/student-exam-detail.component.ts @@ -63,8 +63,9 @@ export class StudentExamDetailComponent implements OnInit, OnDestroy { .subscribe(([data, params, url]) => { this.examId = params.examId; this.courseId = params.courseId; - this.setStudentExamWithGrade(data.studentExam); - this.isTestExam = data.studentExam.exam?.testExam; + const studentExamWithGrade = data.studentExam as StudentExamWithGradeDTO; + this.setStudentExamWithGrade(studentExamWithGrade); + this.isTestExam = studentExamWithGrade.studentExam?.exam?.testExam || false; this.isTestRun = url[1]?.toString() === 'test-runs'; }); } @@ -215,7 +216,7 @@ export class StudentExamDetailComponent implements OnInit, OnDestroy { this.isSaving = false; }, error: () => { - this.alertService.error('artemisApp.studentExamDetail.togglefailed'); + this.alertService.error('artemisApp.studentExamDetail.toggleFailed'); this.isSaving = false; }, }); diff --git a/src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.html b/src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.html index 4ac667e4b3e2..18894afaeda3 100644 --- a/src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.html +++ b/src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.html @@ -1,6 +1,6 @@ {{ studentExam.workingTime! | artemisDurationFromSeconds }} - @if (!isTestRun && percentDifference !== 0) { + @if (!isTestRun && !isTestExam && percentDifference !== 0) { ({{ percentDifference > 0 ? '+' : '' }}{{ percentDifference }} %) } diff --git a/src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.ts b/src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.ts index d1e6fcf1bbe3..e32a0ae2afb2 100644 --- a/src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.ts +++ b/src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.ts @@ -12,11 +12,13 @@ export class StudentExamWorkingTimeComponent implements OnInit { percentDifference = 0; isTestRun = false; + isTestExam = false; ngOnInit() { - if (this.studentExam.exam && this.studentExam.workingTime) { + this.isTestRun = this.studentExam.testRun ?? false; + this.isTestExam = this.studentExam.exam?.testExam ?? false; + if (this.studentExam.exam && this.studentExam.workingTime && !this.isTestRun && !this.isTestExam) { this.percentDifference = getRelativeWorkingTimeExtension(this.studentExam.exam, this.studentExam.workingTime); } - this.isTestRun = this.studentExam.testRun ?? false; } } diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-members.component.ts b/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-members.component.ts index 9efb29312453..4164cce5f8ca 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-members.component.ts +++ b/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-members.component.ts @@ -28,9 +28,9 @@ export class ConversationMembersComponent implements OnInit, OnDestroy { private readonly search$ = new Subject(); - course = input(); - activeConversationInput = input(); - activeConversation = signal(null); + course = input.required(); + activeConversationInput = input.required(); + activeConversation = signal(undefined); changesPerformed = output(); canAddUsersToConversation = canAddUsersToConversation; @@ -115,8 +115,8 @@ export class ConversationMembersComponent implements OnInit, OnDestroy { switchMap(() => { if (this.course()?.id && this.activeConversation()?.id) { return this.conversationService.searchMembersOfConversation( - this.course()?.id!, - this.activeConversation()?.id!, + this.course().id!, + this.activeConversation()!.id!, this.searchTerm, this.page - 1, this.itemsPerPage, diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-settings/conversation-settings.component.ts b/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-settings/conversation-settings.component.ts index 3b1268fafeb8..e9e5743b66d7 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-settings/conversation-settings.component.ts +++ b/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-settings/conversation-settings.component.ts @@ -24,8 +24,8 @@ import { catchError } from 'rxjs/operators'; export class ConversationSettingsComponent implements OnInit, OnDestroy { private ngUnsubscribe = new Subject(); - activeConversation = input(); - course = input(); + activeConversation = input.required(); + course = input.required(); channelArchivalChange = output(); channelDeleted = output(); @@ -54,14 +54,14 @@ export class ConversationSettingsComponent implements OnInit, OnDestroy { this.canLeaveConversation = canLeaveConversation(conversation); this.conversationAsChannel = getAsChannelDTO(conversation); this.canChangeChannelArchivalState = this.conversationAsChannel ? canChangeChannelArchivalState(this.conversationAsChannel) : false; - this.canDeleteChannel = this.conversationAsChannel ? canDeleteChannel(this.course()!, this.conversationAsChannel) : false; + this.canDeleteChannel = this.conversationAsChannel ? canDeleteChannel(this.course(), this.conversationAsChannel) : false; } leaveConversation($event: MouseEvent) { $event.stopPropagation(); if (isGroupChatDTO(this.activeConversation()!)) { this.groupChatService - .removeUsersFromGroupChat(this.course()!.id!, this.activeConversation()?.id!) + .removeUsersFromGroupChat(this.course().id!, this.activeConversation().id!) .pipe(takeUntil(this.ngUnsubscribe)) .subscribe(() => { this.conversationLeave.emit(); @@ -69,7 +69,7 @@ export class ConversationSettingsComponent implements OnInit, OnDestroy { return; } else if (isChannelDTO(this.activeConversation()!)) { this.channelService - .deregisterUsersFromChannel(this.course()!.id!, this.activeConversation()?.id!) + .deregisterUsersFromChannel(this.course().id!, this.activeConversation().id!) .pipe(takeUntil(this.ngUnsubscribe)) .subscribe(() => { this.conversationLeave.emit(); @@ -97,30 +97,16 @@ export class ConversationSettingsComponent implements OnInit, OnDestroy { confirmButtonKey: 'artemisApp.dialogs.archiveChannel.confirmButton', }; - const translationParams = { - channelName: channel.name, - }; + const modalRef = this.createModal(channel, event, keys); - event.stopPropagation(); - const modalRef: NgbModalRef = this.modalService.open(GenericConfirmationDialogComponent, defaultSecondLayerDialogOptions); - modalRef.componentInstance.translationParameters = translationParams; - modalRef.componentInstance.translationKeys = keys; - modalRef.componentInstance.canBeUndone = true; - modalRef.componentInstance.initialize(); - - from(modalRef.result) - .pipe( - catchError(() => EMPTY), - takeUntil(this.ngUnsubscribe), - ) - .subscribe(() => { - this.channelService.archive(this.course()?.id!, channel.id!).subscribe({ - next: () => { - this.channelArchivalChange.emit(); - }, - error: (errorResponse: HttpErrorResponse) => onError(this.alertService, errorResponse), - }); + this.openModal(modalRef, () => { + this.channelService.archive(this.course().id!, channel.id!).subscribe({ + next: () => { + this.channelArchivalChange.emit(); + }, + error: (errorResponse: HttpErrorResponse) => onError(this.alertService, errorResponse), }); + }); } openUnArchivalModal(event: MouseEvent) { @@ -135,34 +121,38 @@ export class ConversationSettingsComponent implements OnInit, OnDestroy { descriptionKey: 'artemisApp.dialogs.unArchiveChannel.description', confirmButtonKey: 'artemisApp.dialogs.unArchiveChannel.confirmButton', }; + const modalRef = this.createModal(channel, event, keys); - const translationParams = { - channelName: channel.name, - }; - - event.stopPropagation(); - const modalRef: NgbModalRef = this.modalService.open(GenericConfirmationDialogComponent, defaultSecondLayerDialogOptions); - modalRef.componentInstance.translationParameters = translationParams; - modalRef.componentInstance.translationKeys = keys; - modalRef.componentInstance.canBeUndone = true; - modalRef.componentInstance.initialize(); + this.openModal(modalRef, () => { + this.channelService + .unarchive(this.course().id!, channel.id!) + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe({ + next: () => { + this.channelArchivalChange.emit(); + }, + error: (errorResponse: HttpErrorResponse) => onError(this.alertService, errorResponse), + }); + }); + } + private openModal(modalRef: NgbModalRef, unArchiveObservable: () => void) { from(modalRef.result) .pipe( catchError(() => EMPTY), takeUntil(this.ngUnsubscribe), ) - .subscribe(() => { - this.channelService - .unarchive(this.course()?.id!, channel.id!) - .pipe(takeUntil(this.ngUnsubscribe)) - .subscribe({ - next: () => { - this.channelArchivalChange.emit(); - }, - error: (errorResponse: HttpErrorResponse) => onError(this.alertService, errorResponse), - }); - }); + .subscribe(unArchiveObservable); + } + + private createModal(channel: ChannelDTO, event: MouseEvent, keys: { titleKey: string; questionKey: string; descriptionKey: string; confirmButtonKey: string }): NgbModalRef { + event.stopPropagation(); + const modalRef: NgbModalRef = this.modalService.open(GenericConfirmationDialogComponent, defaultSecondLayerDialogOptions); + modalRef.componentInstance.translationParameters = { channelName: channel.name }; + modalRef.componentInstance.translationKeys = keys; + modalRef.componentInstance.canBeUndone = true; + modalRef.componentInstance.initialize(); + return modalRef; } deleteChannel() { @@ -171,7 +161,7 @@ export class ConversationSettingsComponent implements OnInit, OnDestroy { return; } this.channelService - .delete(this.course()?.id!, channel.id!) + .delete(this.course().id!, channel.id!) .pipe(takeUntil(this.ngUnsubscribe)) .subscribe({ next: () => { diff --git a/src/main/webapp/i18n/de/exam.json b/src/main/webapp/i18n/de/exam.json index 34cea3d9900d..db2c485e9ed2 100644 --- a/src/main/webapp/i18n/de/exam.json +++ b/src/main/webapp/i18n/de/exam.json @@ -776,7 +776,8 @@ } }, "studentExamDetail": { - "studentExam": "Klausur ({{ examTitle }})", + "studentExam": "Studierenden Klausur ({{ examTitle }})", + "studentTestExam": "Studierenden Test Klausur ({{ examTitle }})", "testRun": "Testlauf ({{ examTitle }})", "exercises": "Aufgaben", "overview": "Übersicht", @@ -800,7 +801,7 @@ "endOfIndividualWorkingTime": "Ende der individuellen Bearbeitungszeit:", "disabledChangeSubmissionStateButtonExplanation": "Der Abgabestatus kann erst nach Ende der Bearbeitungszeit und Ablauf der Karenzzeit geändert werden.", "toggleSuccessful": "Der Abgabestatus wurde erfolgreich geändert.", - "togglefailed": "Der Abgabestatus konnte nicht geändert werden!", + "toggleFailed": "Der Abgabestatus konnte nicht geändert werden!", "hasComplaint": "Beschwerde existiert", "noSessions": "Keine Sitzungen" }, diff --git a/src/main/webapp/i18n/en/exam.json b/src/main/webapp/i18n/en/exam.json index 374506d757af..6b853a1de896 100644 --- a/src/main/webapp/i18n/en/exam.json +++ b/src/main/webapp/i18n/en/exam.json @@ -777,6 +777,7 @@ }, "studentExamDetail": { "studentExam": "Student exam ({{ examTitle }})", + "studentTestExam": "Student test exam ({{ examTitle }})", "testRun": "Test Run ({{ examTitle }})", "exercises": "Exercises", "overview": "Overview", @@ -800,7 +801,7 @@ "endOfIndividualWorkingTime": "End of individual working time:", "disabledChangeSubmissionStateButtonExplanation": "Cannot be changed before the end of the working time and the respective grace period.", "toggleSuccessful": "The submitted status was changed successfully.", - "togglefailed": "The submitted status could not be changed!", + "toggleFailed": "The submitted status could not be changed!", "hasComplaint": "Has Complaint", "noSessions": "No Sessions" }, diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-members.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-members.component.spec.ts index d4f4e305d886..0ea2089fa489 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-members.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-members.component.spec.ts @@ -77,8 +77,9 @@ examples.forEach((activeConversation) => { fixture = TestBed.createComponent(ConversationMembersComponent); TestBed.runInInjectionContext(() => { component = fixture.componentInstance; - component.activeConversation = signal(activeConversation); + component.activeConversationInput = input(activeConversation); component.course = input(course); + component.activeConversation = signal(activeConversation); }); component.canAddUsersToConversation = canAddUsersToConversation; }); From a0bf56d7f541ccf090c99a17503dc4525745109f Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Sun, 15 Dec 2024 20:54:37 +0100 Subject: [PATCH 04/12] Development: Update server dependencies --- build.gradle | 16 ++++++++-------- gradle.properties | 12 ++++++------ gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/build.gradle b/build.gradle index 8fc4fe7ca77d..00f7bbb49d39 100644 --- a/build.gradle +++ b/build.gradle @@ -268,7 +268,7 @@ dependencies { } } - implementation "org.apache.logging.log4j:log4j-to-slf4j:2.24.2" + implementation "org.apache.logging.log4j:log4j-to-slf4j:2.24.3" // Note: spring-security-lti13 does not work with jakarta yet, so we built our own custom version and declare its transitive dependencies below // implementation "uk.ac.ox.ctl:spring-security-lti13:0.1.11" @@ -337,7 +337,7 @@ dependencies { implementation "tech.jhipster:jhipster-framework:${jhipster_dependencies_version}" implementation "org.springframework.boot:spring-boot-starter-cache:${spring_boot_version}" - implementation "io.micrometer:micrometer-registry-prometheus:1.14.1" + implementation "io.micrometer:micrometer-registry-prometheus:1.14.2" implementation "net.logstash.logback:logstash-logback-encoder:8.0" // Defines low-level streaming API, and includes JSON-specific implementations @@ -365,7 +365,7 @@ dependencies { implementation "com.zaxxer:HikariCP:6.2.1" - implementation "org.apache.commons:commons-text:1.12.0" + implementation "org.apache.commons:commons-text:1.13.0" implementation "org.apache.commons:commons-math3:3.6.1" implementation "javax.transaction:javax.transaction-api:1.3" @@ -389,8 +389,8 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server:${spring_boot_version}" implementation "org.springframework.boot:spring-boot-starter-oauth2-client:${spring_boot_version}" - implementation "org.springframework.ldap:spring-ldap-core:3.2.8" - implementation "org.springframework.data:spring-data-ldap:3.4.0" + implementation "org.springframework.ldap:spring-ldap-core:3.2.10" + implementation "org.springframework.data:spring-data-ldap:3.4.1" implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client:${spring_cloud_version}") { // NOTE: these modules contain security vulnerabilities and are not needed @@ -401,9 +401,9 @@ dependencies { implementation "org.springframework.cloud:spring-cloud-commons:${spring_cloud_version}" implementation "io.netty:netty-all:4.1.115.Final" - implementation "io.projectreactor.netty:reactor-netty:1.2.0" + implementation "io.projectreactor.netty:reactor-netty:1.2.1" implementation "org.springframework:spring-messaging:${spring_framework_version}" - implementation "org.springframework.retry:spring-retry:2.0.10" + implementation "org.springframework.retry:spring-retry:2.0.11" implementation "org.springframework.security:spring-security-config:${spring_security_version}" implementation "org.springframework.security:spring-security-data:${spring_security_version}" @@ -607,7 +607,7 @@ tasks.withType(Test).configureEach { } wrapper { - gradleVersion = "8.11.1" + gradleVersion = "8.12-rc-1" } tasks.register("stage") { diff --git a/gradle.properties b/gradle.properties index f2a82add9928..de944d16001d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,13 +2,13 @@ rootProject.name=Artemis profile=dev # Build properties -node_version=22.10.0 -npm_version=10.8.0 +node_version=22.12.0 +npm_version=10.9.0 # Dependency versions jhipster_dependencies_version=8.7.2 spring_boot_version=3.4.0 -spring_framework_version=6.2.0 +spring_framework_version=6.2.1 spring_cloud_version=4.2.0 spring_security_version=6.4.1 # TODO: upgrading to 6.6.x currently leads to issues due to internal changes in Hibernate and potentially wrong use in Artemis server code @@ -21,15 +21,15 @@ hazelcast_version=5.5.0 fasterxml_version=2.18.2 jgit_version=7.1.0.202411261347-r sshd_version=2.14.0 -checkstyle_version=10.20.2 +checkstyle_version=10.21.0 jplag_version=5.1.0 # not really used in Artemis, nor JPlag, nor the used version of Stanford CoreNLP, but we use the latest to avoid security vulnerability warnings # NOTE: we cannot need to use the latest version 9.x or 10.x here as long as Stanford CoreNLP does not reference it lucene_version=8.11.4 slf4j_version=2.0.16 -sentry_version=7.18.1 +sentry_version=7.19.0 liquibase_version=4.30.0 -docker_java_version=3.4.0 +docker_java_version=3.4.1 logback_version=1.5.12 java_parser_version=3.26.2 byte_buddy_version=1.15.10 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c820046..9e40988550fd 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-rc-1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 64a0d1e6adf03f8601cac4533dfe1cb8fab29a4c Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Sun, 15 Dec 2024 20:54:47 +0100 Subject: [PATCH 05/12] Development: Update client dependencies --- package-lock.json | 154 ++++++++++++++++++++-------------------------- package.json | 20 +++--- 2 files changed, 78 insertions(+), 96 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4f327c0959a8..fa8ddae8b113 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "@ng-bootstrap/ng-bootstrap": "17.0.1", "@ngx-translate/core": "16.0.3", "@ngx-translate/http-loader": "16.0.0", - "@sentry/angular": "8.43.0", + "@sentry/angular": "8.45.0", "@siemens/ngx-datatable": "22.4.1", "@swimlane/ngx-charts": "21.0.0", "@swimlane/ngx-graph": "8.4.0", @@ -90,7 +90,7 @@ "@angular/cli": "18.2.12", "@angular/compiler-cli": "18.2.13", "@angular/language-service": "18.2.13", - "@sentry/types": "8.43.0", + "@sentry/types": "8.45.0", "@types/crypto-js": "4.2.2", "@types/d3-shape": "3.1.6", "@types/dompurify": "3.0.5", @@ -106,7 +106,7 @@ "@types/uuid": "10.0.0", "@typescript-eslint/eslint-plugin": "8.18.0", "@typescript-eslint/parser": "8.18.0", - "eslint": "9.16.0", + "eslint": "9.17.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", "eslint-plugin-jest": "28.9.0", @@ -126,7 +126,7 @@ "ngxtension": "4.2.0", "prettier": "3.4.2", "rimraf": "6.0.1", - "sass": "1.82.0", + "sass": "1.83.0", "ts-jest": "29.2.5", "typescript": "5.5.4", "weak-napi": "2.0.2" @@ -3570,9 +3570,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.16.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.16.0.tgz", - "integrity": "sha512-tw2HxzQkrbeuvyj1tG2Yqq+0H9wGoI2IMk4EOsQeX+vmd75FtJAzf+gTA69WF+baUKRYQ3x2kbLE08js5OsTVg==", + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz", + "integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==", "dev": true, "license": "MIT", "engines": { @@ -6367,63 +6367,63 @@ } }, "node_modules/@sentry-internal/browser-utils": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.43.0.tgz", - "integrity": "sha512-5WhJZ3SA5sZVDBwOsChDd5JCzYcwBX7sEqBqEcm3pFru6TUihEnFIJmDIbreIyrQMwUhs3dTxnfnidgjr5z1Ag==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.45.0.tgz", + "integrity": "sha512-MX/E/C+W5I9jkGD1PsbZ2hpCc7YuizNKmEbuGPxQPfUSIPrdE2wpo6ZfIhEbxq9m/trl1oRCN4PXi3BB7dlYYg==", "license": "MIT", "dependencies": { - "@sentry/core": "8.43.0" + "@sentry/core": "8.45.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/feedback": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.43.0.tgz", - "integrity": "sha512-rcGR2kzFu4vLXBQbI9eGJwjyToyjl36O2q/UKbiZBNJ5IFtDvKRLke6jIHq/YqiHPfFGpVtq5M/lYduDfA/eaQ==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.45.0.tgz", + "integrity": "sha512-WerpfkKrKPAlnQuqjEgKXZtrx68cla7GyOkNOeL40JQbY4/By4Qjx1atUOmgk/FdjrCLPw+jQQY9pXRpMRqqRw==", "license": "MIT", "dependencies": { - "@sentry/core": "8.43.0" + "@sentry/core": "8.45.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/replay": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.43.0.tgz", - "integrity": "sha512-geV5/zejLfGGwWHjylzrb1w8NI3U37GMG9/53nmv13FmTXUDF5XF2lh41KXFVYwvp7Ha4bd1FRQ9IU9YtBWskw==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.45.0.tgz", + "integrity": "sha512-SOFwFpzx0B6lxhLl2hBnxvybo7gdB5TMY8dOHMwXgk5A2+BXvSpvWXnr33yqUlBmC8R3LeFTB3C0plzM5lhkJg==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "8.43.0", - "@sentry/core": "8.43.0" + "@sentry-internal/browser-utils": "8.45.0", + "@sentry/core": "8.45.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.43.0.tgz", - "integrity": "sha512-rL8G7E1GtozH8VNalRrBQNjYDJ5ChWS/vpQI5hUG11PZfvQFXEVatLvT3uO2l0xIlHm4idTsHOSLTe/usxnogQ==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.45.0.tgz", + "integrity": "sha512-LZ8kBuzO5gutDiWnCyYEzBMDLq9PIllcsWsXRpKoau0Zqs3DbyRolI11dNnxmUSh7UW21FksxBpqn5yPmUMbag==", "license": "MIT", "dependencies": { - "@sentry-internal/replay": "8.43.0", - "@sentry/core": "8.43.0" + "@sentry-internal/replay": "8.45.0", + "@sentry/core": "8.45.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/angular": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-8.43.0.tgz", - "integrity": "sha512-lxNMWAnEW38g1spvURKxY+MRnQgH/LA0yUY+KNnsMZQdgPTJpRpfPIGczkXXxrzq+PoSetjjBiqKImswwAqJAQ==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-8.45.0.tgz", + "integrity": "sha512-RD8Dsy5moPJnCQeEMuVFEj3igxDpL/7C1Lq+cJnt9CdSRZQS0LGs0BUpymwjKMFywgI84NcACud2Qm9g1s6mWg==", "license": "MIT", "dependencies": { - "@sentry/browser": "8.43.0", - "@sentry/core": "8.43.0", + "@sentry/browser": "8.45.0", + "@sentry/core": "8.45.0", "tslib": "^2.4.1" }, "engines": { @@ -6437,38 +6437,38 @@ } }, "node_modules/@sentry/browser": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.43.0.tgz", - "integrity": "sha512-LGvLLnfmR8+AEgFmd7Q7KHiOTiV0P1Lvio2ENDELhEqJOIiICauttibVmig+AW02qg4kMeywvleMsUYaZv2RVA==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.45.0.tgz", + "integrity": "sha512-Y+BcfpXY1eEkOYOzgLGkx1YH940uMAymYOxfSZSvC+Vx6xHuaGT05mIFef/aeZbyu2AUs6JjdvD1BRBZlHg78w==", "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "8.43.0", - "@sentry-internal/feedback": "8.43.0", - "@sentry-internal/replay": "8.43.0", - "@sentry-internal/replay-canvas": "8.43.0", - "@sentry/core": "8.43.0" + "@sentry-internal/browser-utils": "8.45.0", + "@sentry-internal/feedback": "8.45.0", + "@sentry-internal/replay": "8.45.0", + "@sentry-internal/replay-canvas": "8.45.0", + "@sentry/core": "8.45.0" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/core": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.43.0.tgz", - "integrity": "sha512-ktyovtjkTMNud+kC/XfqHVCoQKreIKgx/hgeRvzPwuPyd1t1KzYmRL3DBkbcWVnyOPpVTHn+RsEI1eRcVYHtvw==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.45.0.tgz", + "integrity": "sha512-4YTuBipWSh4JrtSYS5GxUQBAcAgOIkEoFfFbwVcr3ivijOacJLRXTBn3rpcy1CKjBq0PHDGR+2RGRYC+bNAMxg==", "license": "MIT", "engines": { "node": ">=14.18" } }, "node_modules/@sentry/types": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.43.0.tgz", - "integrity": "sha512-NdTfD3S+od4RTw7xn+4sVSO5n63N/9pUsNT5s0D1QiGGZw0DpENoIb3J/PiGfuWL5f02Bmv7l9vVW0ovWZDWPg==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.45.0.tgz", + "integrity": "sha512-gFwNZn7PkPJzQQXFepSRQbDBSvuITw2wamfi613S3ruaO0lFG7TETGOxazDGGTLINveWjX98YTOOz1Z91XkqlQ==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/core": "8.43.0" + "@sentry/core": "8.45.0" }, "engines": { "node": ">=14.18" @@ -9389,9 +9389,9 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.1.tgz", - "integrity": "sha512-Xd8lFX4LM9QEEwxQpF9J9NTUh8pmdJO0cyRJhFiDoLTk2eH8FXlRv2IFGYVadZpqI3j8fhNrSdKCeYPxiAhLXw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "dev": true, "license": "MIT", "engines": { @@ -9981,9 +9981,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -10659,9 +10659,9 @@ } }, "node_modules/eslint": { - "version": "9.16.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.16.0.tgz", - "integrity": "sha512-whp8mSQI4C8VXd+fLgSM0lh3UlmcFtVwUQjyKCFfsp+2ItAIYhlq/hqGahGqHE6cv9unM41VlqKk2VtKYR2TaA==", + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz", + "integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==", "dev": true, "license": "MIT", "dependencies": { @@ -10670,7 +10670,7 @@ "@eslint/config-array": "^0.19.0", "@eslint/core": "^0.9.0", "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.16.0", + "@eslint/js": "9.17.0", "@eslint/plugin-kit": "^0.2.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -10679,7 +10679,7 @@ "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", - "cross-spawn": "^7.0.5", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.2.0", @@ -12412,23 +12412,6 @@ "node": ">=18" } }, - "node_modules/html-entities": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", - "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" - } - ], - "license": "MIT" - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -17194,9 +17177,9 @@ } }, "node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", "dev": true, "funding": [ { @@ -17215,7 +17198,7 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.1.0", + "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, "engines": { @@ -18355,9 +18338,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.82.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.82.0.tgz", - "integrity": "sha512-j4GMCTa8elGyN9A7x7bEglx0VgSpNUG4W4wNedQ33wSMdnkqQCT8HTwOaVSV4e6yQovcu/3Oc4coJP/l0xhL2Q==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.83.0.tgz", + "integrity": "sha512-qsSxlayzoOjdvXMVLkzF84DJFc2HZEL/rFyGIKbbilYtAvlCxyuzUeff9LawTn4btVnLKg75Z8MMr1lxU1lfGw==", "dev": true, "license": "MIT", "dependencies": { @@ -21217,9 +21200,9 @@ } }, "node_modules/webpack-dev-server": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.1.0.tgz", - "integrity": "sha512-aQpaN81X6tXie1FoOB7xlMfCsN19pSvRAeYUHOdFWOlhpQ/LlbfTqYwwmEDFV0h8GGuqmCmKmT+pxcUV/Nt2gQ==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.0.tgz", + "integrity": "sha512-90SqqYXA2SK36KcT6o1bvwvZfJFcmoamqeJY7+boioffX9g9C0wjjJRGUrQIuh43pb0ttX7+ssavmj/WN2RHtA==", "dev": true, "license": "MIT", "dependencies": { @@ -21236,10 +21219,9 @@ "colorette": "^2.0.10", "compression": "^1.7.4", "connect-history-api-fallback": "^2.0.0", - "express": "^4.19.2", + "express": "^4.21.2", "graceful-fs": "^4.2.6", - "html-entities": "^2.4.0", - "http-proxy-middleware": "^2.0.3", + "http-proxy-middleware": "^2.0.7", "ipaddr.js": "^2.1.0", "launch-editor": "^2.6.1", "open": "^10.0.3", diff --git a/package.json b/package.json index cd429fd6f913..0143bd6bfb7e 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@ng-bootstrap/ng-bootstrap": "17.0.1", "@ngx-translate/core": "16.0.3", "@ngx-translate/http-loader": "16.0.0", - "@sentry/angular": "8.43.0", + "@sentry/angular": "8.45.0", "@siemens/ngx-datatable": "22.4.1", "@swimlane/ngx-charts": "21.0.0", "@swimlane/ngx-graph": "8.4.0", @@ -91,24 +91,24 @@ "d3-transition": "^3.0.1" }, "@typescript-eslint/utils": { - "eslint": "^9.16.0" + "eslint": "^9.17.0" }, "braces": "3.0.3", - "cookie": "1.0.1", + "cookie": "1.0.2", "critters": "0.0.25", - "debug": "4.3.7", + "debug": "4.4.0", "eslint-plugin-deprecation": { - "eslint": "^9.16.0" + "eslint": "^9.17.0" }, "express": "5.0.1", "jsdom": "25.0.1", - "postcss": "8.4.47", + "postcss": "8.4.49", "rimraf": "6.0.1", "semver": "7.6.3", "tough-cookie": "5.0.0", "vite": "5.4.11", "webpack-dev-middleware": "7.4.2", - "webpack-dev-server": "5.1.0", + "webpack-dev-server": "5.2.0", "word-wrap": "1.2.5", "ws": "8.18.0", "yargs-parser": "21.1.1" @@ -124,7 +124,7 @@ "@angular/cli": "18.2.12", "@angular/compiler-cli": "18.2.13", "@angular/language-service": "18.2.13", - "@sentry/types": "8.43.0", + "@sentry/types": "8.45.0", "@types/crypto-js": "4.2.2", "@types/d3-shape": "3.1.6", "@types/dompurify": "3.0.5", @@ -140,7 +140,7 @@ "@types/uuid": "10.0.0", "@typescript-eslint/eslint-plugin": "8.18.0", "@typescript-eslint/parser": "8.18.0", - "eslint": "9.16.0", + "eslint": "9.17.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", "eslint-plugin-jest": "28.9.0", @@ -160,7 +160,7 @@ "ng-mocks": "14.13.1", "prettier": "3.4.2", "rimraf": "6.0.1", - "sass": "1.82.0", + "sass": "1.83.0", "ts-jest": "29.2.5", "typescript": "5.5.4", "weak-napi": "2.0.2" From 5449cc450bc1bf4c7660f2879dfab49dc3466d8d Mon Sep 17 00:00:00 2001 From: Simon Entholzer <33342534+SimonEntholzer@users.noreply.github.com> Date: Mon, 16 Dec 2024 08:11:49 +0100 Subject: [PATCH 06/12] Development: Remove race condition in faky playwright test (#10013) --- .../playwright/e2e/course/CourseMessages.spec.ts | 5 +++-- .../support/pageobjects/course/CourseMessagesPage.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/test/playwright/e2e/course/CourseMessages.spec.ts b/src/test/playwright/e2e/course/CourseMessages.spec.ts index 291aaaa095b8..d251556937ce 100644 --- a/src/test/playwright/e2e/course/CourseMessages.spec.ts +++ b/src/test/playwright/e2e/course/CourseMessages.spec.ts @@ -142,11 +142,12 @@ test.describe('Course messages', { tag: '@fast' }, () => { await login(instructor, `/courses/${course.id}/communication?conversationId=${channel.id}`); const newName = 'new-test-name'; const topic = 'test-topic'; - await courseMessages.getName().click(); + + // each edit action triggers an update to the server, on multinode this can lead to a race condition await courseMessages.editName(newName); await courseMessages.editTopic(topic); await courseMessages.editDescription('New Description'); - await courseMessages.closeEditPanel(); + await page.reload(); await page.locator('jhi-conversation-header').waitFor({ state: 'visible', timeout: 10000 }); await expect(courseMessages.getName()).toContainText(newName); diff --git a/src/test/playwright/support/pageobjects/course/CourseMessagesPage.ts b/src/test/playwright/support/pageobjects/course/CourseMessagesPage.ts index 623f6c08e6d2..2a07b1fbf404 100644 --- a/src/test/playwright/support/pageobjects/course/CourseMessagesPage.ts +++ b/src/test/playwright/support/pageobjects/course/CourseMessagesPage.ts @@ -170,11 +170,15 @@ export class CourseMessagesPage { * @param newName - The new name for the conversation. */ async editName(newName: string) { + await this.getName().click(); await this.page.locator('#name-section .action-button').click(); const nameField = this.page.locator('.channels-overview #name'); await nameField.clear(); await nameField.fill(newName); await this.page.locator('#submitButton').click(); + await expect(this.page.locator('#name-section textarea')).toHaveValue(newName); + await this.closeEditPanel(); + await this.page.waitForTimeout(200); } /** @@ -182,11 +186,15 @@ export class CourseMessagesPage { * @param newTopic - The new topic for the conversation. */ async editTopic(newTopic: string) { + await this.getName().click(); await this.page.locator('#topic-section .action-button').click(); const topicField = this.page.locator('.channels-overview #topic'); await topicField.clear(); await topicField.fill(newTopic); await this.page.locator('#submitButton').click(); + await expect(this.page.locator('#topic-section textarea')).toHaveValue(newTopic); + await this.closeEditPanel(); + await this.page.waitForTimeout(200); } /** @@ -194,11 +202,15 @@ export class CourseMessagesPage { * @param newDescription - The new description for the conversation. */ async editDescription(newDescription: string) { + await this.getName().click(); await this.page.locator('#description-section .action-button').click(); const descriptionField = this.page.locator('.channels-overview #description'); await descriptionField.clear(); await descriptionField.fill(newDescription); await this.page.locator('#submitButton').click(); + await expect(this.page.locator('#description-section textarea')).toHaveValue(newDescription); + await this.closeEditPanel(); + await this.page.waitForTimeout(200); } /** From 69f72d03176c6e1b07fc9825a7b85605bb23412e Mon Sep 17 00:00:00 2001 From: Mohamed Bilel Besrour <58034472+BBesrour@users.noreply.github.com> Date: Mon, 16 Dec 2024 23:57:57 +0100 Subject: [PATCH 07/12] Integrated code lifecycle: Improve build status updates for users (#9818) --- .../buildagent/dto/BuildJobQueueItem.java | 15 +- .../buildagent/dto/FinishedBuildJobDTO.java | 2 +- .../artemis/buildagent/dto/JobTimingInfo.java | 3 +- .../service/SharedQueueProcessingService.java | 8 +- .../aet/artemis/core/config/Constants.java | 4 + .../artemis/exercise/dto/SubmissionDTO.java | 10 +- .../ProgrammingExerciseBuildStatistics.java | 57 ++++ .../artemis/programming/dto/ResultDTO.java | 2 +- .../dto/SubmissionProcessingDTO.java | 12 + .../repository/BuildJobRepository.java | 16 ++ ...mingExerciseBuildStatisticsRepository.java | 18 ++ .../service/ProgrammingMessagingService.java | 41 ++- .../localci/LocalCIQueueWebsocketService.java | 17 +- .../LocalCIResultProcessingService.java | 79 +++++- .../localci/LocalCITriggerService.java | 26 +- .../localci/SharedQueueManagementService.java | 132 +++++++++ ...grammingExerciseParticipationResource.java | 36 ++- .../web/localci/BuildJobQueueResource.java | 21 ++ .../changelog/20241101130000_changelog.xml | 19 ++ .../resources/config/liquibase/master.xml | 1 + .../programming-submission.model.ts | 3 + .../programming/submission-processing-dto.ts | 10 + ...programming-exam-submission.component.html | 2 + ...de-editor-student-container.component.html | 2 + .../programming-submission.service.ts | 257 +++++++++++++++++- ...xercise-headers-information.component.html | 1 + .../result-progress-bar.component.html | 35 +++ .../result-progress-bar.component.scss | 8 + .../result-progress-bar.component.ts | 126 +++++++++ .../shared/result/result.component.html | 42 ++- .../shared/result/result.component.ts | 28 +- .../exercises/shared/result/result.utils.ts | 10 +- .../result/updating-result.component.html | 5 + .../result/updating-result.component.ts | 51 +++- .../submission-result-status.component.html | 1 + .../submission-result-status.component.ts | 1 + .../submission-result-status.module.ts | 4 +- src/main/webapp/i18n/de/editor.json | 2 + src/main/webapp/i18n/en/editor.json | 2 + .../icl/LocalCIIntegrationTest.java | 64 +++++ .../icl/LocalCIResourceIntegrationTest.java | 48 +++- .../programming/icl/LocalCIServiceTest.java | 2 +- ...ctSpringIntegrationLocalCILocalVCTest.java | 4 + .../result-progress-bar.component.spec.ts | 112 ++++++++ .../component/shared/result.component.spec.ts | 16 ++ .../shared/updating-result.component.spec.ts | 52 +++- .../mock-programming-submission.service.ts | 3 + .../programming-submission.service.spec.ts | 230 +++++++++++++++- 48 files changed, 1564 insertions(+), 76 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildStatistics.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/programming/dto/SubmissionProcessingDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseBuildStatisticsRepository.java create mode 100644 src/main/resources/config/liquibase/changelog/20241101130000_changelog.xml create mode 100644 src/main/webapp/app/entities/programming/submission-processing-dto.ts create mode 100644 src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.html create mode 100644 src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.scss create mode 100644 src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.ts create mode 100644 src/test/javascript/spec/component/shared/result-progress-bar.component.spec.ts diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildJobQueueItem.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildJobQueueItem.java index 331cb3dd6f77..27bfcdba0e84 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildJobQueueItem.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildJobQueueItem.java @@ -29,8 +29,9 @@ public record BuildJobQueueItem(String id, String name, BuildAgentDTO buildAgent */ public BuildJobQueueItem(BuildJobQueueItem queueItem, ZonedDateTime buildCompletionDate, BuildStatus status) { this(queueItem.id(), queueItem.name(), queueItem.buildAgent(), queueItem.participationId(), queueItem.courseId(), queueItem.exerciseId(), queueItem.retryCount(), - queueItem.priority(), status, queueItem.repositoryInfo(), - new JobTimingInfo(queueItem.jobTimingInfo.submissionDate(), queueItem.jobTimingInfo.buildStartDate(), buildCompletionDate), queueItem.buildConfig(), null); + queueItem.priority(), status, queueItem.repositoryInfo(), new JobTimingInfo(queueItem.jobTimingInfo.submissionDate(), queueItem.jobTimingInfo.buildStartDate(), + buildCompletionDate, queueItem.jobTimingInfo.estimatedCompletionDate(), queueItem.jobTimingInfo.estimatedDuration()), + queueItem.buildConfig(), null); } /** @@ -39,9 +40,11 @@ public BuildJobQueueItem(BuildJobQueueItem queueItem, ZonedDateTime buildComplet * @param queueItem The queued build job * @param buildAgent The build agent that will process the build job */ - public BuildJobQueueItem(BuildJobQueueItem queueItem, BuildAgentDTO buildAgent) { + public BuildJobQueueItem(BuildJobQueueItem queueItem, BuildAgentDTO buildAgent, ZonedDateTime estimatedCompletionDate) { this(queueItem.id(), queueItem.name(), buildAgent, queueItem.participationId(), queueItem.courseId(), queueItem.exerciseId(), queueItem.retryCount(), queueItem.priority(), - null, queueItem.repositoryInfo(), new JobTimingInfo(queueItem.jobTimingInfo.submissionDate(), ZonedDateTime.now(), null), queueItem.buildConfig(), null); + null, queueItem.repositoryInfo(), + new JobTimingInfo(queueItem.jobTimingInfo.submissionDate(), ZonedDateTime.now(), null, estimatedCompletionDate, queueItem.jobTimingInfo.estimatedDuration()), + queueItem.buildConfig(), null); } public BuildJobQueueItem(BuildJobQueueItem queueItem, ResultDTO submissionResult) { @@ -51,6 +54,8 @@ public BuildJobQueueItem(BuildJobQueueItem queueItem, ResultDTO submissionResult public BuildJobQueueItem(BuildJobQueueItem queueItem, BuildAgentDTO buildAgent, int newRetryCount) { this(queueItem.id(), queueItem.name(), buildAgent, queueItem.participationId(), queueItem.courseId(), queueItem.exerciseId(), newRetryCount, queueItem.priority(), null, - queueItem.repositoryInfo(), new JobTimingInfo(queueItem.jobTimingInfo.submissionDate(), ZonedDateTime.now(), null), queueItem.buildConfig(), null); + queueItem.repositoryInfo(), + new JobTimingInfo(queueItem.jobTimingInfo.submissionDate(), ZonedDateTime.now(), null, null, queueItem.jobTimingInfo().estimatedDuration()), + queueItem.buildConfig(), null); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/FinishedBuildJobDTO.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/FinishedBuildJobDTO.java index c7086c9acf29..d7df788928aa 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/FinishedBuildJobDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/FinishedBuildJobDTO.java @@ -36,7 +36,7 @@ public record ResultDTO(Long id, ZonedDateTime completionDate, Boolean successfu * @return the converted DTO */ public static ResultDTO of(Result result) { - SubmissionDTO submissionDTO = result.getSubmission() == null ? null : SubmissionDTO.of(result.getSubmission()); + SubmissionDTO submissionDTO = result.getSubmission() == null ? null : SubmissionDTO.of(result.getSubmission(), false, null, null); return new ResultDTO(result.getId(), result.getCompletionDate(), result.isSuccessful(), result.getScore(), result.isRated(), ParticipationDTO.of(result.getParticipation()), submissionDTO, result.getAssessmentType(), result.getTestCaseCount(), result.getPassedTestCaseCount(), diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/JobTimingInfo.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/JobTimingInfo.java index 8de38115f6b6..f344bff2c3a8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/JobTimingInfo.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/JobTimingInfo.java @@ -10,5 +10,6 @@ // in the future are migrated or cleared. Changes should be communicated in release notes as potentially breaking changes. @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record JobTimingInfo(ZonedDateTime submissionDate, ZonedDateTime buildStartDate, ZonedDateTime buildCompletionDate) implements Serializable { +public record JobTimingInfo(ZonedDateTime submissionDate, ZonedDateTime buildStartDate, ZonedDateTime buildCompletionDate, ZonedDateTime estimatedCompletionDate, + long estimatedDuration) implements Serializable { } diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java index 6bf67b2626e2..fb58d1a5d6c6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java @@ -305,7 +305,10 @@ private BuildJobQueueItem addToProcessingJobs() { if (buildJob != null) { String hazelcastMemberAddress = hazelcastInstance.getCluster().getLocalMember().getAddress().toString(); - BuildJobQueueItem processingJob = new BuildJobQueueItem(buildJob, new BuildAgentDTO(buildAgentShortName, hazelcastMemberAddress, buildAgentDisplayName)); + long estimatedDuration = Math.max(0, buildJob.jobTimingInfo().estimatedDuration()); + ZonedDateTime estimatedCompletionDate = ZonedDateTime.now().plusSeconds(estimatedDuration); + BuildJobQueueItem processingJob = new BuildJobQueueItem(buildJob, new BuildAgentDTO(buildAgentShortName, hazelcastMemberAddress, buildAgentDisplayName), + estimatedCompletionDate); processingJobs.put(processingJob.id(), processingJob); localProcessingJobs.incrementAndGet(); @@ -403,7 +406,8 @@ private void processBuild(BuildJobQueueItem buildJob) { futureResult.thenAccept(buildResult -> { log.debug("Build job completed: {}", buildJob); - JobTimingInfo jobTimingInfo = new JobTimingInfo(buildJob.jobTimingInfo().submissionDate(), buildJob.jobTimingInfo().buildStartDate(), ZonedDateTime.now()); + JobTimingInfo jobTimingInfo = new JobTimingInfo(buildJob.jobTimingInfo().submissionDate(), buildJob.jobTimingInfo().buildStartDate(), ZonedDateTime.now(), + buildJob.jobTimingInfo().estimatedCompletionDate(), buildJob.jobTimingInfo().estimatedDuration()); BuildJobQueueItem finishedJob = new BuildJobQueueItem(buildJob.id(), buildJob.name(), buildJob.buildAgent(), buildJob.participationId(), buildJob.courseId(), buildJob.exerciseId(), buildJob.retryCount(), buildJob.priority(), BuildStatus.SUCCESSFUL, buildJob.repositoryInfo(), jobTimingInfo, buildJob.buildConfig(), diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java index 357a0d5a02e1..274b9e393ecb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java @@ -66,6 +66,10 @@ public final class Constants { public static final String NEW_SUBMISSION_TOPIC = "/topic" + PROGRAMMING_SUBMISSION_TOPIC; + public static final String SUBMISSION_PROCESSING = "/submissionProcessing"; + + public static final String SUBMISSION_PROCESSING_TOPIC = "/topic" + SUBMISSION_PROCESSING; + public static final String ATHENA_PROGRAMMING_EXERCISE_REPOSITORY_API_PATH = "/api/public/athena/programming-exercises/"; // short names should have at least 3 characters and must start with a letter diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/dto/SubmissionDTO.java b/src/main/java/de/tum/cit/aet/artemis/exercise/dto/SubmissionDTO.java index 8abb58b3bd3a..93798b78d645 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/dto/SubmissionDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/dto/SubmissionDTO.java @@ -15,7 +15,8 @@ */ @JsonInclude(JsonInclude.Include.NON_EMPTY) public record SubmissionDTO(Long id, Boolean submitted, SubmissionType type, Boolean exampleSubmission, ZonedDateTime submissionDate, String commitHash, Boolean buildFailed, - Boolean buildArtifact, ParticipationDTO participation, String submissionExerciseType) implements Serializable { + Boolean buildArtifact, ParticipationDTO participation, String submissionExerciseType, boolean isProcessing, ZonedDateTime buildStartDate, + ZonedDateTime estimatedCompletionDate) implements Serializable { /** * Converts a Submission into a SubmissionDTO. @@ -23,15 +24,16 @@ public record SubmissionDTO(Long id, Boolean submitted, SubmissionType type, Boo * @param submission to convert * @return the converted DTO */ - public static SubmissionDTO of(Submission submission) { + public static SubmissionDTO of(Submission submission, boolean isProcessing, ZonedDateTime buildStartDate, ZonedDateTime estimatedCompletionDate) { if (submission instanceof ProgrammingSubmission programmingSubmission) { // For programming submissions we need to extract additional information (e.g. the commit hash) and send it to the client return new SubmissionDTO(programmingSubmission.getId(), programmingSubmission.isSubmitted(), programmingSubmission.getType(), programmingSubmission.isExampleSubmission(), programmingSubmission.getSubmissionDate(), programmingSubmission.getCommitHash(), programmingSubmission.isBuildFailed(), programmingSubmission.isBuildArtifact(), ParticipationDTO.of(programmingSubmission.getParticipation()), - programmingSubmission.getSubmissionExerciseType()); + programmingSubmission.getSubmissionExerciseType(), isProcessing, buildStartDate, estimatedCompletionDate); } return new SubmissionDTO(submission.getId(), submission.isSubmitted(), submission.getType(), submission.isExampleSubmission(), submission.getSubmissionDate(), null, null, - null, ParticipationDTO.of(submission.getParticipation()), submission.getSubmissionExerciseType()); + null, ParticipationDTO.of(submission.getParticipation()), submission.getSubmissionExerciseType(), false, null, null); } + } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildStatistics.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildStatistics.java new file mode 100644 index 000000000000..56ab517a7761 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExerciseBuildStatistics.java @@ -0,0 +1,57 @@ +package de.tum.cit.aet.artemis.programming.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.domain.DomainObject; + +@Entity +@Table(name = "programming_exercise_build_statistics") +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public class ProgrammingExerciseBuildStatistics extends DomainObject { + + @Column(name = "build_duration_seconds") + private long buildDurationSeconds = 0; + + @Column(name = "build_count_when_updated") + private long buildCountWhenUpdated = 0; + + @Column(name = "exercise_id") + private Long exerciseId; + + public ProgrammingExerciseBuildStatistics() { + } + + public ProgrammingExerciseBuildStatistics(Long exerciseId, long buildDurationSeconds, long buildCountWhenUpdated) { + this.buildDurationSeconds = buildDurationSeconds; + this.buildCountWhenUpdated = buildCountWhenUpdated; + this.exerciseId = exerciseId; + } + + public long getBuildDurationSeconds() { + return buildDurationSeconds; + } + + public void setBuildDurationSeconds(long buildDurationSeconds) { + this.buildDurationSeconds = buildDurationSeconds; + } + + public long getBuildCountWhenUpdated() { + return buildCountWhenUpdated; + } + + public void setBuildCountWhenUpdated(long buildCountWhenUpdated) { + this.buildCountWhenUpdated = buildCountWhenUpdated; + } + + public Long getExerciseId() { + return exerciseId; + } + + public void setExerciseId(Long exerciseId) { + this.exerciseId = exerciseId; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/dto/ResultDTO.java b/src/main/java/de/tum/cit/aet/artemis/programming/dto/ResultDTO.java index cfab7c3a3840..609bdd1d77f4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/dto/ResultDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/dto/ResultDTO.java @@ -63,7 +63,7 @@ public static ResultDTO of(Result result) { public static ResultDTO of(Result result, List filteredFeedback) { SubmissionDTO submissionDTO = null; if (Hibernate.isInitialized(result.getSubmission()) && result.getSubmission() != null) { - submissionDTO = SubmissionDTO.of(result.getSubmission()); + submissionDTO = SubmissionDTO.of(result.getSubmission(), false, null, null); } var feedbackDTOs = filteredFeedback.stream().map(FeedbackDTO::of).toList(); return new ResultDTO(result.getId(), result.getCompletionDate(), result.isSuccessful(), result.getScore(), result.isRated(), submissionDTO, diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/dto/SubmissionProcessingDTO.java b/src/main/java/de/tum/cit/aet/artemis/programming/dto/SubmissionProcessingDTO.java new file mode 100644 index 000000000000..7cae77cae3b3 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/dto/SubmissionProcessingDTO.java @@ -0,0 +1,12 @@ +package de.tum.cit.aet.artemis.programming.dto; + +import java.io.Serial; +import java.io.Serializable; +import java.time.ZonedDateTime; + +public record SubmissionProcessingDTO(long exerciseId, long participationId, String commitHash, ZonedDateTime submissionDate, ZonedDateTime buildStartDate, + ZonedDateTime estimatedCompletionDate) implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildJobRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildJobRepository.java index d7a5e662c744..834a7dfa621e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildJobRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/BuildJobRepository.java @@ -104,4 +104,20 @@ SELECT COUNT(b) WHERE e.id IN :exerciseIds """) long countBuildJobsByExerciseIds(@Param("exerciseIds") List exerciseIds); + + @Query(""" + SELECT b + FROM BuildJob b + WHERE b.exerciseId = :exerciseId AND b.buildStatus = 'SUCCESSFUL' + ORDER BY b.buildStartDate DESC + LIMIT :limit + """) + List fetchSuccessfulBuildJobsByExerciseIdWithLimit(@Param("exerciseId") Long exerciseId, @Param("limit") int limit); + + @Query(""" + SELECT COUNT(b) + FROM BuildJob b + WHERE b.exerciseId = :exerciseId AND b.buildStatus = 'SUCCESSFUL' + """) + long fetchSuccessfulBuildJobCountByExerciseId(@Param("exerciseId") Long exerciseId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseBuildStatisticsRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseBuildStatisticsRepository.java new file mode 100644 index 000000000000..cbb58583cbb2 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseBuildStatisticsRepository.java @@ -0,0 +1,18 @@ +package de.tum.cit.aet.artemis.programming.repository; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; + +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildStatistics; + +@Profile(PROFILE_CORE) +@Repository +public interface ProgrammingExerciseBuildStatisticsRepository extends ArtemisJpaRepository { + + Optional findByExerciseId(Long exerciseId); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingMessagingService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingMessagingService.java index 8c73f1b25850..349aa53bc89e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingMessagingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingMessagingService.java @@ -6,6 +6,8 @@ import static de.tum.cit.aet.artemis.core.config.Constants.NEW_SUBMISSION_TOPIC; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import static de.tum.cit.aet.artemis.core.config.Constants.PROGRAMMING_SUBMISSION_TOPIC; +import static de.tum.cit.aet.artemis.core.config.Constants.SUBMISSION_PROCESSING; +import static de.tum.cit.aet.artemis.core.config.Constants.SUBMISSION_PROCESSING_TOPIC; import static de.tum.cit.aet.artemis.core.config.Constants.TEST_CASES_CHANGED_RUN_COMPLETED_NOTIFICATION; import java.util.Optional; @@ -24,6 +26,7 @@ import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; import de.tum.cit.aet.artemis.exercise.domain.participation.StudentParticipation; import de.tum.cit.aet.artemis.exercise.dto.SubmissionDTO; +import de.tum.cit.aet.artemis.exercise.repository.ParticipationRepository; import de.tum.cit.aet.artemis.exercise.repository.TeamRepository; import de.tum.cit.aet.artemis.iris.service.pyris.PyrisEventService; import de.tum.cit.aet.artemis.iris.service.pyris.event.NewResultEvent; @@ -33,6 +36,7 @@ import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingSubmission; import de.tum.cit.aet.artemis.programming.domain.build.BuildRunState; +import de.tum.cit.aet.artemis.programming.dto.SubmissionProcessingDTO; import de.tum.cit.aet.artemis.programming.exception.BuildTriggerWebsocketError; @Profile(PROFILE_CORE) @@ -53,14 +57,17 @@ public class ProgrammingMessagingService { private final Optional pyrisEventService; + private final ParticipationRepository participationRepository; + public ProgrammingMessagingService(GroupNotificationService groupNotificationService, WebsocketMessagingService websocketMessagingService, ResultWebsocketService resultWebsocketService, Optional ltiNewResultService, TeamRepository teamRepository, - Optional pyrisEventService) { + Optional pyrisEventService, ParticipationRepository participationRepository) { this.groupNotificationService = groupNotificationService; this.websocketMessagingService = websocketMessagingService; this.resultWebsocketService = resultWebsocketService; this.ltiNewResultService = ltiNewResultService; this.teamRepository = teamRepository; + this.participationRepository = participationRepository; this.pyrisEventService = pyrisEventService; } @@ -68,6 +75,10 @@ private static String getExerciseTopicForTAAndAbove(long exerciseId) { return EXERCISE_TOPIC_ROOT + exerciseId + PROGRAMMING_SUBMISSION_TOPIC; } + private static String getSubmissionProcessingTopicForTAAndAbove(Long exerciseId) { + return EXERCISE_TOPIC_ROOT + exerciseId + SUBMISSION_PROCESSING; + } + public static String getProgrammingExerciseTestCaseChangedTopic(Long programmingExerciseId) { return "/topic/programming-exercises/" + programmingExerciseId + "/test-cases-changed"; } @@ -95,7 +106,7 @@ public void notifyInstructorAboutCompletedExerciseBuildRun(ProgrammingExercise p * @param exerciseId used to build the correct topic */ public void notifyUserAboutSubmission(ProgrammingSubmission submission, Long exerciseId) { - var submissionDTO = SubmissionDTO.of(submission); + var submissionDTO = SubmissionDTO.of(submission, false, null, null); if (submission.getParticipation() instanceof StudentParticipation studentParticipation) { if (studentParticipation.getParticipant() instanceof Team team) { // eager load the team with students so their information can be used for the messages below @@ -204,4 +215,30 @@ private void notifyIrisAboutSubmissionStatus(Result result, ProgrammingExerciseS }); } } + + /** + * Notifies the user about the processing of a submission. + * This method sends a notification to the user that their submission is processing + * It handles both student participations and template/solution participations. + * + * @param submission the submission processing data transfer object containing the submission details + * @param exerciseId the ID of the exercise associated with the submission + * @param participationId the ID of the participation associated with the submission + */ + public void notifyUserAboutSubmissionProcessing(SubmissionProcessingDTO submission, long exerciseId, long participationId) { + Participation participation = participationRepository.findWithProgrammingExerciseWithBuildConfigById(participationId).orElseThrow(); + if (participation instanceof StudentParticipation studentParticipation) { + if (studentParticipation.getParticipant() instanceof Team team) { + // Eagerly load the team with students so their information can be used for the messages below + studentParticipation.setParticipant(teamRepository.findWithStudentsByIdElseThrow(team.getId())); + } + studentParticipation.getStudents().forEach(user -> websocketMessagingService.sendMessageToUser(user.getLogin(), SUBMISSION_PROCESSING_TOPIC, submission)); + } + + // send an update to tutors, editors and instructors about submissions for template and solution participations + if (!(participation instanceof StudentParticipation)) { + String topicDestination = getSubmissionProcessingTopicForTAAndAbove(exerciseId); + websocketMessagingService.sendMessage(topicDestination, submission); + } + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java index 0e081a93728b..872057e95145 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIQueueWebsocketService.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.artemis.programming.service.localci; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; @@ -24,6 +25,8 @@ import de.tum.cit.aet.artemis.buildagent.dto.BuildConfig; import de.tum.cit.aet.artemis.buildagent.dto.BuildJobQueueItem; import de.tum.cit.aet.artemis.buildagent.dto.RepositoryInfo; +import de.tum.cit.aet.artemis.programming.dto.SubmissionProcessingDTO; +import de.tum.cit.aet.artemis.programming.service.ProgrammingMessagingService; /** * This service is responsible for sending build job queue information over websockets. @@ -39,6 +42,8 @@ public class LocalCIQueueWebsocketService { private final LocalCIWebsocketMessagingService localCIWebsocketMessagingService; + private final ProgrammingMessagingService programmingMessagingService; + private final SharedQueueManagementService sharedQueueManagementService; private final HazelcastInstance hazelcastInstance; @@ -51,10 +56,11 @@ public class LocalCIQueueWebsocketService { * @param sharedQueueManagementService the local ci shared build job queue service */ public LocalCIQueueWebsocketService(@Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance, LocalCIWebsocketMessagingService localCIWebsocketMessagingService, - SharedQueueManagementService sharedQueueManagementService) { + SharedQueueManagementService sharedQueueManagementService, ProgrammingMessagingService programmingMessagingService) { this.hazelcastInstance = hazelcastInstance; this.localCIWebsocketMessagingService = localCIWebsocketMessagingService; this.sharedQueueManagementService = sharedQueueManagementService; + this.programmingMessagingService = programmingMessagingService; } /** @@ -118,6 +124,9 @@ private class ProcessingBuildJobItemListener implements EntryAddedListener event) { log.debug("CIBuildJobQueueItem added to processing jobs: {}", event.getValue()); sendProcessingJobsOverWebsocket(event.getValue().courseId()); + notifyUserAboutBuildProcessing(event.getValue().exerciseId(), event.getValue().participationId(), event.getValue().buildConfig().assignmentCommitHash(), + event.getValue().jobTimingInfo().submissionDate(), event.getValue().jobTimingInfo().buildStartDate(), + event.getValue().jobTimingInfo().estimatedCompletionDate()); } @Override @@ -201,4 +210,10 @@ private static List removeUnnecessaryInformationFromBuild } return filteredBuildAgentSummary; } + + private void notifyUserAboutBuildProcessing(long exerciseId, long participationId, String commitHash, ZonedDateTime submissionDate, ZonedDateTime buildStartDate, + ZonedDateTime estimatedCompletionDate) { + var submissionProcessingDTO = new SubmissionProcessingDTO(exerciseId, participationId, commitHash, submissionDate, buildStartDate, estimatedCompletionDate); + programmingMessagingService.notifyUserAboutSubmissionProcessing(submissionProcessingDTO, exerciseId, participationId); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java index 417ee5bdc630..a453c7accf5b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIResultProcessingService.java @@ -2,10 +2,13 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_LOCALCI; +import java.time.Duration; import java.util.List; import java.util.Optional; +import java.util.OptionalDouble; import java.util.UUID; import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; import jakarta.annotation.PreDestroy; @@ -35,6 +38,8 @@ import de.tum.cit.aet.artemis.exercise.domain.SubmissionType; import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; import de.tum.cit.aet.artemis.exercise.repository.ParticipationRepository; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildStatistics; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; import de.tum.cit.aet.artemis.programming.domain.RepositoryType; import de.tum.cit.aet.artemis.programming.domain.build.BuildJob; @@ -42,6 +47,7 @@ import de.tum.cit.aet.artemis.programming.dto.ResultDTO; import de.tum.cit.aet.artemis.programming.exception.BuildTriggerWebsocketError; import de.tum.cit.aet.artemis.programming.repository.BuildJobRepository; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildStatisticsRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; import de.tum.cit.aet.artemis.programming.service.BuildLogEntryService; import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseGradingService; @@ -52,6 +58,10 @@ @Service public class LocalCIResultProcessingService { + private static final int BUILD_STATISTICS_UPDATE_THRESHOLD = 10; + + private static final int BUILD_JOB_DURATION_UPDATE_LIMIT = 100; + private static final Logger log = LoggerFactory.getLogger(LocalCIResultProcessingService.class); private final HazelcastInstance hazelcastInstance; @@ -64,6 +74,8 @@ public class LocalCIResultProcessingService { private final ProgrammingExerciseRepository programmingExerciseRepository; + private final ProgrammingExerciseBuildStatisticsRepository programmingExerciseBuildStatisticsRepository; + private final ParticipationRepository participationRepository; private final ProgrammingTriggerService programmingTriggerService; @@ -78,7 +90,8 @@ public class LocalCIResultProcessingService { public LocalCIResultProcessingService(@Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance, ProgrammingExerciseGradingService programmingExerciseGradingService, ProgrammingMessagingService programmingMessagingService, BuildJobRepository buildJobRepository, ProgrammingExerciseRepository programmingExerciseRepository, - ParticipationRepository participationRepository, ProgrammingTriggerService programmingTriggerService, BuildLogEntryService buildLogEntryService) { + ParticipationRepository participationRepository, ProgrammingTriggerService programmingTriggerService, BuildLogEntryService buildLogEntryService, + ProgrammingExerciseBuildStatisticsRepository programmingExerciseBuildStatisticsRepository) { this.hazelcastInstance = hazelcastInstance; this.programmingExerciseRepository = programmingExerciseRepository; this.participationRepository = participationRepository; @@ -87,6 +100,7 @@ public LocalCIResultProcessingService(@Qualifier("hazelcastInstance") HazelcastI this.buildJobRepository = buildJobRepository; this.programmingTriggerService = programmingTriggerService; this.buildLogEntryService = buildLogEntryService; + this.programmingExerciseBuildStatisticsRepository = programmingExerciseBuildStatisticsRepository; } /** @@ -159,6 +173,13 @@ public void processResult() { } } finally { + ProgrammingExerciseParticipation programmingExerciseParticipation = (ProgrammingExerciseParticipation) participationOptional.orElse(null); + if (programmingExerciseParticipation != null && programmingExerciseParticipation.getExercise() == null) { + ProgrammingExercise exercise = programmingExerciseRepository.getProgrammingExerciseWithBuildConfigFromParticipation(programmingExerciseParticipation); + programmingExerciseParticipation.setExercise(exercise); + programmingExerciseParticipation.setProgrammingExercise(exercise); + } + // save build job to database if (ex != null) { if (ex.getCause() instanceof CancellationException && ex.getMessage().equals("Build job with id " + buildJob.id() + " was cancelled.")) { @@ -171,26 +192,24 @@ public void processResult() { } else { savedBuildJob = saveFinishedBuildJob(buildJob, BuildStatus.SUCCESSFUL, result); - } - - if (participationOptional.isPresent()) { - ProgrammingExerciseParticipation participation = (ProgrammingExerciseParticipation) participationOptional.get(); - if (participation.getExercise() == null) { - participation.setExercise(programmingExerciseRepository.getProgrammingExerciseFromParticipation(participation)); + if (programmingExerciseParticipation != null) { + updateExerciseBuildDurationAsync(programmingExerciseParticipation.getProgrammingExercise()); } + } + if (programmingExerciseParticipation != null) { if (result != null) { - programmingMessagingService.notifyUserAboutNewResult(result, participation); + programmingMessagingService.notifyUserAboutNewResult(result, programmingExerciseParticipation); addResultToBuildAgentsRecentBuildJobs(buildJob, result); } else { - programmingMessagingService.notifyUserAboutSubmissionError((Participation) participation, - new BuildTriggerWebsocketError("Result could not be processed", participation.getId())); + programmingMessagingService.notifyUserAboutSubmissionError((Participation) programmingExerciseParticipation, + new BuildTriggerWebsocketError("Result could not be processed", programmingExerciseParticipation.getId())); } if (!buildLogs.isEmpty()) { if (savedBuildJob != null) { - buildLogEntryService.saveBuildLogsToFile(buildLogs, savedBuildJob.getBuildJobId(), participation.getProgrammingExercise()); + buildLogEntryService.saveBuildLogsToFile(buildLogs, savedBuildJob.getBuildJobId(), programmingExerciseParticipation.getProgrammingExercise()); } else { log.warn("Couldn't save build logs as build job {} was not saved", buildJob.id()); @@ -253,7 +272,7 @@ private void addResultToBuildAgentsRecentBuildJobs(BuildJobQueueItem buildJob, R * * @return the saved the build job */ - public BuildJob saveFinishedBuildJob(BuildJobQueueItem queueItem, BuildStatus buildStatus, Result result) { + private BuildJob saveFinishedBuildJob(BuildJobQueueItem queueItem, BuildStatus buildStatus, Result result) { try { BuildJob buildJob = new BuildJob(queueItem, buildStatus, result); return buildJobRepository.save(buildJob); @@ -264,6 +283,42 @@ public BuildJob saveFinishedBuildJob(BuildJobQueueItem queueItem, BuildStatus bu } } + private void updateExerciseBuildDurationAsync(ProgrammingExercise exercise) { + CompletableFuture.runAsync(() -> updateExerciseBuildDuration(exercise)); + } + + private void updateExerciseBuildDuration(ProgrammingExercise exercise) { + try { + ProgrammingExerciseBuildStatistics buildStatistics = programmingExerciseBuildStatisticsRepository.findByExerciseId(exercise.getId()).orElse(null); + long successfulBuildJobCountByExerciseId = buildJobRepository.fetchSuccessfulBuildJobCountByExerciseId(exercise.getId()); + + boolean hasSuccessfulBuildJobs = successfulBuildJobCountByExerciseId > 0; + boolean exceedsUpdateThreshold = buildStatistics == null + || successfulBuildJobCountByExerciseId - buildStatistics.getBuildCountWhenUpdated() >= BUILD_STATISTICS_UPDATE_THRESHOLD; + + boolean shouldUpdate = hasSuccessfulBuildJobs && exceedsUpdateThreshold; + if (!shouldUpdate) { + return; + } + + OptionalDouble averageBuildDuration = buildJobRepository.fetchSuccessfulBuildJobsByExerciseIdWithLimit(exercise.getId(), BUILD_JOB_DURATION_UPDATE_LIMIT).stream() + .mapToLong(buildJob -> Duration.between(buildJob.getBuildStartDate(), buildJob.getBuildCompletionDate()).toSeconds()).average(); + if (averageBuildDuration.isPresent()) { + if (buildStatistics == null) { + buildStatistics = new ProgrammingExerciseBuildStatistics(exercise.getId(), Math.round(averageBuildDuration.getAsDouble()), successfulBuildJobCountByExerciseId); + } + else { + buildStatistics.setBuildDurationSeconds((long) averageBuildDuration.getAsDouble()); + buildStatistics.setBuildCountWhenUpdated(successfulBuildJobCountByExerciseId); + } + programmingExerciseBuildStatisticsRepository.save(buildStatistics); + } + } + catch (Exception e) { + log.error("Could not update exercise build duration", e); + } + } + public class ResultQueueListener implements ItemListener { @Override diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java index 0870d1a1d04d..683830123870 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCITriggerService.java @@ -37,12 +37,14 @@ import de.tum.cit.aet.artemis.programming.domain.AuxiliaryRepository; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildConfig; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildStatistics; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage; import de.tum.cit.aet.artemis.programming.domain.ProjectType; import de.tum.cit.aet.artemis.programming.domain.RepositoryType; import de.tum.cit.aet.artemis.programming.repository.AuxiliaryRepositoryRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildConfigRepository; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildStatisticsRepository; import de.tum.cit.aet.artemis.programming.repository.SolutionProgrammingExerciseParticipationRepository; import de.tum.cit.aet.artemis.programming.service.BuildScriptProviderService; import de.tum.cit.aet.artemis.programming.service.GitService; @@ -97,6 +99,8 @@ public class LocalCITriggerService implements ContinuousIntegrationTriggerServic private final ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository; + private final ProgrammingExerciseBuildStatisticsRepository programmingExerciseBuildStatisticsRepository; + private IQueue queue; private IMap dockerImageCleanupInfo; @@ -105,13 +109,19 @@ public class LocalCITriggerService implements ContinuousIntegrationTriggerServic private final ProgrammingExerciseBuildConfigService programmingExerciseBuildConfigService; + private static final int DEFAULT_BUILD_DURATION = 17; + + // Arbitrary value to ensure that the build duration is always a bit higher than the actual build duration + private static final double BUILD_DURATION_SAFETY_FACTOR = 1.2; + public LocalCITriggerService(@Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance, AeolusTemplateService aeolusTemplateService, ProgrammingLanguageConfiguration programmingLanguageConfiguration, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository, LocalCIProgrammingLanguageFeatureService programmingLanguageFeatureService, Optional versionControlService, SolutionProgrammingExerciseParticipationRepository solutionProgrammingExerciseParticipationRepository, LocalCIBuildConfigurationService localCIBuildConfigurationService, GitService gitService, ExerciseDateService exerciseDateService, ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository, BuildScriptProviderService buildScriptProviderService, - ProgrammingExerciseBuildConfigService programmingExerciseBuildConfigService) { + ProgrammingExerciseBuildConfigService programmingExerciseBuildConfigService, + ProgrammingExerciseBuildStatisticsRepository programmingExerciseBuildStatisticsRepository) { this.hazelcastInstance = hazelcastInstance; this.aeolusTemplateService = aeolusTemplateService; this.programmingLanguageConfiguration = programmingLanguageConfiguration; @@ -125,6 +135,7 @@ public LocalCITriggerService(@Qualifier("hazelcastInstance") HazelcastInstance h this.exerciseDateService = exerciseDateService; this.buildScriptProviderService = buildScriptProviderService; this.programmingExerciseBuildConfigService = programmingExerciseBuildConfigService; + this.programmingExerciseBuildStatisticsRepository = programmingExerciseBuildStatisticsRepository; } @PostConstruct @@ -195,10 +206,15 @@ else if (triggeredByPushTo.equals(RepositoryType.TESTS)) { String buildJobId = String.valueOf(participation.getId()) + submissionDate.toInstant().toEpochMilli(); - JobTimingInfo jobTimingInfo = new JobTimingInfo(submissionDate, null, null); - var programmingExerciseBuildConfig = loadBuildConfig(programmingExercise); + var buildStatics = loadBuildStatistics(programmingExercise); + + long estimatedDuration = (buildStatics != null && buildStatics.getBuildDurationSeconds() > 0) ? buildStatics.getBuildDurationSeconds() : DEFAULT_BUILD_DURATION; + estimatedDuration = Math.round(estimatedDuration * BUILD_DURATION_SAFETY_FACTOR); + + JobTimingInfo jobTimingInfo = new JobTimingInfo(submissionDate, null, null, null, estimatedDuration); + RepositoryInfo repositoryInfo = getRepositoryInfo(participation, triggeredByPushTo, programmingExerciseBuildConfig); BuildConfig buildConfig = getBuildConfig(participation, commitHashToBuild, assignmentCommitHash, testCommitHash, programmingExerciseBuildConfig); @@ -335,6 +351,10 @@ private ProgrammingExerciseBuildConfig loadBuildConfig(ProgrammingExercise progr return programmingExerciseBuildConfigRepository.getProgrammingExerciseBuildConfigElseThrow(programmingExercise); } + private ProgrammingExerciseBuildStatistics loadBuildStatistics(ProgrammingExercise programmingExercise) { + return programmingExerciseBuildStatisticsRepository.findByExerciseId(programmingExercise.getId()).orElse(null); + } + /** * Determines the priority of the build job. * Lower values indicate higher priority. diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java index f03325c503d8..1ed01bf6e7ac 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java @@ -5,9 +5,11 @@ import java.time.Duration; import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.PriorityQueue; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -28,6 +30,9 @@ import com.hazelcast.collection.IQueue; import com.hazelcast.core.HazelcastInstance; import com.hazelcast.map.IMap; +import com.hazelcast.map.listener.EntryAddedListener; +import com.hazelcast.map.listener.EntryRemovedListener; +import com.hazelcast.map.listener.EntryUpdatedListener; import com.hazelcast.topic.ITopic; import de.tum.cit.aet.artemis.buildagent.dto.BuildAgentInformation; @@ -71,6 +76,10 @@ public class SharedQueueManagementService { private ITopic resumeBuildAgentTopic; + private int buildAgentsCapacity; + + private int runningBuildJobCount; + public SharedQueueManagementService(BuildJobRepository buildJobRepository, @Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance, ProfileService profileService) { this.buildJobRepository = buildJobRepository; this.hazelcastInstance = hazelcastInstance; @@ -89,6 +98,8 @@ public void init() { this.dockerImageCleanupInfo = this.hazelcastInstance.getMap("dockerImageCleanupInfo"); this.pauseBuildAgentTopic = hazelcastInstance.getTopic("pauseBuildAgentTopic"); this.resumeBuildAgentTopic = hazelcastInstance.getTopic("resumeBuildAgentTopic"); + this.buildAgentInformation.addEntryListener(new BuildAgentListener(), false); + this.updateBuildAgentCapacity(); } /** @@ -323,4 +334,125 @@ public Page getFilteredFinishedBuildJobs(FinishedBuildJobPageableSearc return new PageImpl<>(orderedBuildJobs, buildJobIdsPage.getPageable(), buildJobIdsPage.getTotalElements()); } + + /** + * Estimates how long the job will be queued for on the participation ID. + * + * @param participationId the ID of the participation for which the queue release date is estimated + * @return the estimated queue release date as a {@link ZonedDateTime} + */ + public ZonedDateTime getBuildJobEstimatedStartDate(long participationId) { + if (queue.isEmpty() || this.buildAgentsCapacity > this.runningBuildJobCount + queue.size()) { + return ZonedDateTime.now(); + } + + String buildJobId = getIdOfQueuedJobFromParticipation(participationId); + + if (buildJobId == null) { + return ZonedDateTime.now(); + } + + // Get the jobs queued before the job for the participation + List jobsQueuedBefore = getQueuedJobs().stream().sorted(new LocalCIPriorityQueueComparator()).takeWhile(job -> !job.id().equals(buildJobId)).toList(); + + ZonedDateTime now = ZonedDateTime.now(); + + // Get the remaining duration of the build jobs currently being processed + List agentsAvailabilities = new ArrayList<>(getProcessingJobs().stream().map(job -> getBuildJobRemainingDuration(job, now)).sorted().toList()); + + if (agentsAvailabilities.size() < this.buildAgentsCapacity) { + int agentsToAdd = this.buildAgentsCapacity - agentsAvailabilities.size(); + agentsAvailabilities.addAll(Collections.nCopies(agentsToAdd, 0L)); + } + else { + agentsAvailabilities = agentsAvailabilities.subList(0, this.buildAgentsCapacity); + log.warn("There are more processing jobs than the build agents' capacity. This should not happen. Processing jobs: {}, Build agents: {}", processingJobs, + buildAgentInformation); + } + + if (jobsQueuedBefore.size() < agentsAvailabilities.size()) { + return now.plusSeconds(agentsAvailabilities.get(jobsQueuedBefore.size())); + } + else { + return now.plusSeconds(calculateNextJobQueueDuration(agentsAvailabilities, jobsQueuedBefore)); + } + } + + private String getIdOfQueuedJobFromParticipation(long participationId) { + var participationBuildJobIds = getQueuedJobs().stream().filter(job -> job.participationId() == participationId).map(BuildJobQueueItem::id).toList(); + if (participationBuildJobIds.isEmpty()) { + return null; + } + return participationBuildJobIds.getLast(); + } + + private Long calculateNextJobQueueDuration(List agentsAvailabilities, List jobsQueuedBefore) { + PriorityQueue agentAvailabilitiesQueue = new PriorityQueue<>(agentsAvailabilities); + for (BuildJobQueueItem job : jobsQueuedBefore) { + Long agentRemainingTimeObj = agentAvailabilitiesQueue.poll(); + long agentRemainingTime = agentRemainingTimeObj == null ? 0 : agentRemainingTimeObj; + agentRemainingTime = Math.max(0, agentRemainingTime); + agentAvailabilitiesQueue.add(agentRemainingTime + job.jobTimingInfo().estimatedDuration()); + } + Long agentRemainingTimeObj = agentAvailabilitiesQueue.poll(); + return agentRemainingTimeObj == null ? 0 : agentRemainingTimeObj; + } + + private long getBuildJobRemainingDuration(BuildJobQueueItem buildJob, ZonedDateTime now) { + ZonedDateTime estimatedCompletionDate = buildJob.jobTimingInfo().estimatedCompletionDate(); + if (estimatedCompletionDate == null) { + return 0; + } + if (estimatedCompletionDate.isBefore(now)) { + return 0; + } + return Duration.between(now, estimatedCompletionDate).toSeconds(); + + } + + private class BuildAgentListener + implements EntryAddedListener, EntryRemovedListener, EntryUpdatedListener { + + @Override + public void entryAdded(com.hazelcast.core.EntryEvent event) { + log.debug("Build agent added: {}", event.getValue()); + updateBuildAgentCapacity(); + } + + @Override + public void entryRemoved(com.hazelcast.core.EntryEvent event) { + log.debug("Build agent removed: {}", event.getOldValue()); + updateBuildAgentCapacity(); + } + + @Override + public void entryUpdated(com.hazelcast.core.EntryEvent event) { + log.debug("Build agent updated: {}", event.getValue()); + updateBuildAgentCapacity(); + } + } + + private void updateBuildAgentCapacity() { + buildAgentsCapacity = getBuildAgentInformation().stream().mapToInt(BuildAgentInformation::maxNumberOfConcurrentBuildJobs).sum(); + runningBuildJobCount = getBuildAgentInformation().stream().mapToInt(BuildAgentInformation::numberOfCurrentBuildJobs).sum(); + } + + /** + * Check if a submission is currently being processed. + * + * @param participationId the id of the participation + * @param commitHash the commit hash + * @return the build start date and estimated completion date of the submission if it is currently being processed, null otherwise + */ + public BuildTimingInfo isSubmissionProcessing(long participationId, String commitHash) { + var buildJob = getProcessingJobs().stream().filter(job -> job.participationId() == participationId && Objects.equals(commitHash, job.buildConfig().assignmentCommitHash())) + .findFirst(); + if (buildJob.isPresent()) { + return new BuildTimingInfo(buildJob.get().jobTimingInfo().buildStartDate(), buildJob.get().jobTimingInfo().estimatedCompletionDate()); + } + return null; + } + + public record BuildTimingInfo(ZonedDateTime buildStartDate, ZonedDateTime estimatedCompletionDate) { + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java index 2566e1a87fb8..24e97cce95d2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java @@ -39,6 +39,7 @@ import de.tum.cit.aet.artemis.exam.service.ExamService; import de.tum.cit.aet.artemis.exercise.domain.Submission; import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; +import de.tum.cit.aet.artemis.exercise.dto.SubmissionDTO; import de.tum.cit.aet.artemis.exercise.repository.ParticipationRepository; import de.tum.cit.aet.artemis.exercise.service.ParticipationAuthorizationCheckService; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; @@ -57,6 +58,7 @@ import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseParticipationService; import de.tum.cit.aet.artemis.programming.service.ProgrammingSubmissionService; import de.tum.cit.aet.artemis.programming.service.RepositoryService; +import de.tum.cit.aet.artemis.programming.service.localci.SharedQueueManagementService; @Profile(PROFILE_CORE) @RestController @@ -93,11 +95,14 @@ public class ProgrammingExerciseParticipationResource { private final AuxiliaryRepositoryRepository auxiliaryRepositoryRepository; + private final Optional sharedQueueManagementService; + public ProgrammingExerciseParticipationResource(ProgrammingExerciseParticipationService programmingExerciseParticipationService, ResultRepository resultRepository, ParticipationRepository participationRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, ProgrammingSubmissionService submissionService, ProgrammingExerciseRepository programmingExerciseRepository, AuthorizationCheckService authCheckService, ResultService resultService, ParticipationAuthorizationCheckService participationAuthCheckService, RepositoryService repositoryService, - StudentExamRepository studentExamRepository, Optional vcsAccessLogRepository, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository) { + StudentExamRepository studentExamRepository, Optional vcsAccessLogRepository, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository, + Optional sharedQueueManagementService) { this.programmingExerciseParticipationService = programmingExerciseParticipationService; this.participationRepository = participationRepository; this.programmingExerciseStudentParticipationRepository = programmingExerciseStudentParticipationRepository; @@ -111,6 +116,7 @@ public ProgrammingExerciseParticipationResource(ProgrammingExerciseParticipation this.studentExamRepository = studentExamRepository; this.auxiliaryRepositoryRepository = auxiliaryRepositoryRepository; this.vcsAccessLogRepository = vcsAccessLogRepository; + this.sharedQueueManagementService = sharedQueueManagementService; } /** @@ -209,7 +215,7 @@ public ResponseEntity checkIfParticipationHashResult(@PathVariable Long */ @GetMapping("programming-exercise-participations/{participationId}/latest-pending-submission") @EnforceAtLeastStudent - public ResponseEntity getLatestPendingSubmission(@PathVariable Long participationId, @RequestParam(defaultValue = "false") boolean lastGraded) { + public ResponseEntity getLatestPendingSubmission(@PathVariable Long participationId, @RequestParam(defaultValue = "false") boolean lastGraded) { Optional submissionOpt; try { submissionOpt = submissionService.getLatestPendingSubmission(participationId, lastGraded); @@ -217,9 +223,31 @@ public ResponseEntity getLatestPendingSubmission(@PathVar catch (IllegalArgumentException ex) { throw new EntityNotFoundException("participation", participationId); } + if (submissionOpt.isEmpty()) { + return ResponseEntity.ok(null); + } + ProgrammingSubmission programmingSubmission = submissionOpt.get(); + boolean isSubmissionProcessing = false; + ZonedDateTime buildStartDate = null; + ZonedDateTime estimatedCompletionDate = null; + if (sharedQueueManagementService.isPresent()) { + try { + var buildTimingInfo = sharedQueueManagementService.get().isSubmissionProcessing(participationId, programmingSubmission.getCommitHash()); + if (buildTimingInfo != null) { + isSubmissionProcessing = true; + buildStartDate = buildTimingInfo.buildStartDate(); + estimatedCompletionDate = buildTimingInfo.estimatedCompletionDate(); + } + } + catch (Exception e) { + log.warn("Failed to get build timing info for submission {} of participation {}: {}", programmingSubmission.getCommitHash(), participationId, e.getMessage()); + } + } + // Remove participation, is not needed in the response. - submissionOpt.ifPresent(submission -> submission.setParticipation(null)); - return ResponseEntity.ok(submissionOpt.orElse(null)); + programmingSubmission.setParticipation(null); + var submissionDTO = SubmissionDTO.of(programmingSubmission, isSubmissionProcessing, buildStartDate, estimatedCompletionDate); + return ResponseEntity.ok(submissionDTO); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/localci/BuildJobQueueResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/localci/BuildJobQueueResource.java index 1243f455563a..228995b24d87 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/localci/BuildJobQueueResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/localci/BuildJobQueueResource.java @@ -29,8 +29,10 @@ import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastInstructor; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastInstructorInCourse; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; +import de.tum.cit.aet.artemis.core.util.TimeLogUtil; import de.tum.cit.aet.artemis.programming.domain.build.BuildJob; import de.tum.cit.aet.artemis.programming.repository.BuildJobRepository; import de.tum.cit.aet.artemis.programming.service.localci.SharedQueueManagementService; @@ -189,4 +191,23 @@ public ResponseEntity getBuildJobStatistics(@PathVariabl BuildJobsStatisticsDTO buildJobStatistics = BuildJobsStatisticsDTO.of(buildJobResultCountDtos); return ResponseEntity.ok(buildJobStatistics); } + + /** + * Returns the estimated start date of the build job for the given participation. + * + * @param participationId the id of the participation + * @return the estimated queue duration + */ + @GetMapping("queued-jobs/queue-duration-estimation") + @EnforceAtLeastStudent + public ResponseEntity getBuildJobEstimatedStartDate(@RequestParam long participationId) { + var start = System.nanoTime(); + if (participationId <= 0) { + ResponseEntity.badRequest().build(); + } + ZonedDateTime estimatedJobQueueReleaseTime = localCIBuildJobQueueService.getBuildJobEstimatedStartDate(participationId); + log.debug("Queue duration estimation took {} ms", TimeLogUtil.formatDurationFrom(start)); + return ResponseEntity.ok(estimatedJobQueueReleaseTime); + } + } diff --git a/src/main/resources/config/liquibase/changelog/20241101130000_changelog.xml b/src/main/resources/config/liquibase/changelog/20241101130000_changelog.xml new file mode 100644 index 000000000000..52b413e6c87f --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241101130000_changelog.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 9dd06528e6f3..1dbecdb3ea97 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -34,6 +34,7 @@ + diff --git a/src/main/webapp/app/entities/programming/programming-submission.model.ts b/src/main/webapp/app/entities/programming/programming-submission.model.ts index af5176513791..2e3ab53da56f 100644 --- a/src/main/webapp/app/entities/programming/programming-submission.model.ts +++ b/src/main/webapp/app/entities/programming/programming-submission.model.ts @@ -6,6 +6,9 @@ export class ProgrammingSubmission extends Submission { public commitHash?: string; public buildFailed?: boolean; public buildArtifact?: boolean; // whether the result includes a build artifact or not + public isProcessing?: boolean; + public buildStartDate?: dayjs.Dayjs; + public estimatedCompletionDate?: dayjs.Dayjs; constructor() { super(SubmissionExerciseType.PROGRAMMING); diff --git a/src/main/webapp/app/entities/programming/submission-processing-dto.ts b/src/main/webapp/app/entities/programming/submission-processing-dto.ts new file mode 100644 index 000000000000..c158907edacd --- /dev/null +++ b/src/main/webapp/app/entities/programming/submission-processing-dto.ts @@ -0,0 +1,10 @@ +import dayjs from 'dayjs/esm'; + +export class SubmissionProcessingDTO { + public exerciseId?: number; + public participationId?: number; + public commitHash?: string; + public submissionDate?: dayjs.Dayjs; + public estimatedCompletionDate?: dayjs.Dayjs; + public buildStartDate?: dayjs.Dayjs; +} diff --git a/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html b/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html index 35129261f2ec..d6c4633c893f 100644 --- a/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html +++ b/src/main/webapp/app/exam/participate/exercises/programming/programming-exam-submission.component.html @@ -45,6 +45,8 @@

[showBadge]="true" [participation]="studentParticipation" [personalParticipation]="true" + [showProgressBar]="true" + [showProgressBarBorder]="true" class="me-2" /> } diff --git a/src/main/webapp/app/exercises/programming/participate/code-editor-student-container.component.html b/src/main/webapp/app/exercises/programming/participate/code-editor-student-container.component.html index 5fb244524421..2daedf130771 100644 --- a/src/main/webapp/app/exercises/programming/participate/code-editor-student-container.component.html +++ b/src/main/webapp/app/exercises/programming/participate/code-editor-student-container.component.html @@ -75,6 +75,8 @@ [participation]="participation" [personalParticipation]="true" (onParticipationChange)="receivedNewResult()" + [showProgressBar]="true" + [showProgressBarBorder]="true" class="me-2" /> } diff --git a/src/main/webapp/app/exercises/programming/participate/programming-submission.service.ts b/src/main/webapp/app/exercises/programming/participate/programming-submission.service.ts index 0fd3d43a0535..4b5e60dd22d8 100644 --- a/src/main/webapp/app/exercises/programming/participate/programming-submission.service.ts +++ b/src/main/webapp/app/exercises/programming/participate/programming-submission.service.ts @@ -13,6 +13,10 @@ import { ProgrammingExerciseStudentParticipation } from 'app/entities/participat import { findLatestResult } from 'app/shared/util/utils'; import { ProgrammingExerciseParticipationService } from 'app/exercises/programming/manage/services/programming-exercise-participation.service'; import { ParticipationService } from 'app/exercises/shared/participation/participation.service'; +import { SubmissionProcessingDTO } from 'app/entities/programming/submission-processing-dto'; +import dayjs from 'dayjs/esm'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { PROFILE_LOCALCI } from 'app/app.constants'; export enum ProgrammingSubmissionState { // The last submission of participation has a result. @@ -21,9 +25,21 @@ export enum ProgrammingSubmissionState { IS_BUILDING_PENDING_SUBMISSION = 'IS_BUILDING_PENDING_SUBMISSION', // A failed submission is a pending submission that has not received a result within an expected time frame. HAS_FAILED_SUBMISSION = 'HAS_FAILED_SUBMISSION', + // The submission is queued and will be built soon. + IS_QUEUED = 'IS_QUEUED', } -export type ProgrammingSubmissionStateObj = { participationId: number; submissionState: ProgrammingSubmissionState; submission?: ProgrammingSubmission }; +export type ProgrammingSubmissionStateObj = { + participationId: number; + submissionState: ProgrammingSubmissionState; + submission?: ProgrammingSubmission; + buildTimingInfo?: BuildTimingInfo; +}; + +export type BuildTimingInfo = { + estimatedCompletionDate?: dayjs.Dayjs; + buildStartDate?: dayjs.Dayjs; +}; export type ExerciseSubmissionState = { [participationId: number]: ProgrammingSubmissionStateObj }; @@ -54,11 +70,17 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi public PROGRAMMING_EXERCISE_RESOURCE_URL = 'api/programming-exercises/'; // Default value: 2 minutes. private DEFAULT_EXPECTED_RESULT_ETA = 2 * 60 * 1000; + // Default value: 60 seconds. + private DEFAULT_EXPECTED_QUEUE_ESTIMATE = 60 * 1000; private SUBMISSION_TEMPLATE_TOPIC = '/topic/exercise/%exerciseId%/newSubmissions'; + private SUBMISSION_PROCESSING_TEMPLATE_TOPIC = '/topic/exercise/%exerciseId%/submissionProcessing'; + private resultSubscriptions: { [participationId: number]: Subscription } = {}; // participationId -> topic private submissionTopicsSubscribed = new Map(); + // participationId -> topic + private submissionProcessingTopicsSubscribed = new Map(); // participationId -> exerciseId private participationIdToExerciseId = new Map(); @@ -72,20 +94,35 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi private resultTimerSubscriptions: { [participationId: number]: Subscription } = {}; private resultEtaSubject = new BehaviorSubject(this.DEFAULT_EXPECTED_RESULT_ETA); + private queueEstimateTimerSubscriptions: { [participationId: number]: Subscription } = {}; + private exerciseBuildStateValue: { [exerciseId: number]: ExerciseSubmissionState } = {}; private currentExpectedResultETA = this.DEFAULT_EXPECTED_RESULT_ETA; + private currentExpectedQueueEstimate = this.DEFAULT_EXPECTED_QUEUE_ESTIMATE; + + private startedProcessingCache: Map = new Map(); + private isLocalCIProfile?: boolean = undefined; + private profileServiceSubscription: Subscription; constructor( private websocketService: JhiWebsocketService, private http: HttpClient, private participationWebsocketService: ParticipationWebsocketService, private participationService: ProgrammingExerciseParticipationService, - ) {} + private profileService: ProfileService, + ) { + this.profileServiceSubscription = this.profileService.getProfileInfo().subscribe((profileInfo) => { + this.setLocalCIProfile(!!profileInfo?.activeProfiles.includes(PROFILE_LOCALCI)); + }); + } ngOnDestroy(): void { Object.values(this.resultSubscriptions).forEach((sub) => sub.unsubscribe()); Object.values(this.resultTimerSubscriptions).forEach((sub) => sub.unsubscribe()); + Object.values(this.queueEstimateTimerSubscriptions).forEach((sub) => sub.unsubscribe()); this.submissionTopicsSubscribed.forEach((topic) => this.websocketService.unsubscribe(topic)); + this.submissionProcessingTopicsSubscribed.forEach((topic) => this.websocketService.unsubscribe(topic)); + this.profileServiceSubscription.unsubscribe(); } get exerciseBuildState() { @@ -146,6 +183,10 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi .pipe(catchError(() => of([]))); } + public fetchQueueReleaseDateEstimationByParticipationId(participationId: number): Observable { + return this.http.get('api/queued-jobs/queue-duration-estimation', { params: { participationId } }).pipe(catchError(() => of(undefined))); + } + /** * Start a timer after which the timer subject will notify the corresponding subject. * Side effect: Timer will also emit an alert when the time runs out as it means here that no result came for a submission. @@ -173,6 +214,24 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi } } + private startQueueEstimateTimer(participationId: number, exerciseId: number, submission: ProgrammingSubmission, time = this.currentExpectedQueueEstimate) { + this.resetQueueEstimateTimer(participationId); + this.queueEstimateTimerSubscriptions[participationId] = timer(time).subscribe(() => { + const remainingTime = this.getExpectedRemainingTimeForBuild(submission); + if (remainingTime > 0) { + this.emitBuildingSubmission(participationId, exerciseId, submission); + this.startResultWaitingTimer(participationId, remainingTime); + } else { + this.emitFailedSubmission(participationId, exerciseId); + } + this.resetQueueEstimateTimer(participationId); + }); + } + + private resetQueueEstimateTimer(participationId: number) { + this.queueEstimateTimerSubscriptions[participationId]?.unsubscribe(); + } + /** * Set up a submission subscription for the latest pending submission if not yet existing. * @@ -206,8 +265,19 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi } const programmingSubmission = submission as ProgrammingSubmission; const submissionParticipationId = programmingSubmission.participation!.id!; - this.emitBuildingSubmission(submissionParticipationId, this.participationIdToExerciseId.get(submissionParticipationId)!, submission); - // Now we start a timer, if there is no result when the timer runs out, it will notify the subscribers that no result was received and show an error. + let buildTimingInfo: BuildTimingInfo | undefined = undefined; + + if (this.isLocalCIProfile) { + const isSubmissionQueued = this.handleQueuedProgrammingSubmissions(programmingSubmission, submissionParticipationId); + if (isSubmissionQueued) { + return; + } + + buildTimingInfo = this.startedProcessingCache.get(programmingSubmission.commitHash!); + this.removeSubmissionFromProcessingCache(programmingSubmission.commitHash!); + } + + this.emitBuildingSubmission(submissionParticipationId, this.participationIdToExerciseId.get(submissionParticipationId)!, submission, buildTimingInfo); this.startResultWaitingTimer(submissionParticipationId); }), ) @@ -217,6 +287,97 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi } } + private handleQueuedProgrammingSubmissions(programmingSubmission: ProgrammingSubmission, submissionParticipationId: number) { + let isSubmissionQueued = false; + if (!programmingSubmission.isProcessing && !this.didSubmissionStartProcessing(programmingSubmission.commitHash!)) { + const queueRemainingTime = this.getExpectedRemainingTimeForQueue(programmingSubmission); + if (queueRemainingTime > 0) { + this.emitQueuedSubmission(submissionParticipationId, this.participationIdToExerciseId.get(submissionParticipationId)!, programmingSubmission); + this.startQueueEstimateTimer( + submissionParticipationId, + this.participationIdToExerciseId.get(submissionParticipationId)!, + programmingSubmission, + queueRemainingTime, + ); + isSubmissionQueued = true; + } + } + return isSubmissionQueued; + } + + private setupWebsocketSubscriptionForSubmissionProcessing(participationId: number, exerciseId: number, personal: boolean): void { + if (!this.submissionProcessingTopicsSubscribed.get(participationId)) { + let newSubmissionTopic: string; + if (personal) { + newSubmissionTopic = '/user/topic/submissionProcessing'; + } else { + newSubmissionTopic = this.SUBMISSION_PROCESSING_TEMPLATE_TOPIC.replace('%exerciseId%', exerciseId.toString()); + } + + // Only subscribe if not subscription to same topic exists (e.g. from different participation) + const subscriptionOnSameTopicExists = Array.from(this.submissionProcessingTopicsSubscribed.values()).includes(newSubmissionTopic); + if (!subscriptionOnSameTopicExists) { + this.websocketService.subscribe(newSubmissionTopic); + this.websocketService + .receive(newSubmissionTopic) + .pipe( + tap((submissionProcessing: SubmissionProcessingDTO) => { + const submissionParticipationId = submissionProcessing.participationId!; + const exerciseId = this.participationIdToExerciseId.get(submissionParticipationId)!; + + if (!this.isNewestSubmission(submissionProcessing, exerciseId, submissionParticipationId)) { + return; + } + + const programmingSubmission = this.getSubmissionByCommitHash(submissionProcessing); + // It is possible that the submission started processing before it got saved to the database and the message was sent to the client. + // In this case, we cache that the submission started processing and do not emit the building state. + // When the submission message arrives, we check if the submission is already in the cache. + if (!programmingSubmission) { + this.startedProcessingCache.set(submissionProcessing.commitHash!, { + estimatedCompletionDate: submissionProcessing.estimatedCompletionDate, + buildStartDate: submissionProcessing.buildStartDate, + }); + return; + } + programmingSubmission.isProcessing = true; + + const buildTimingInfo = { + estimatedCompletionDate: submissionProcessing.estimatedCompletionDate, + buildStartDate: submissionProcessing.buildStartDate, + }; + this.removeSubmissionFromProcessingCache(programmingSubmission.commitHash!); + this.resetQueueEstimateTimer(submissionParticipationId); + this.emitBuildingSubmission(submissionParticipationId, exerciseId, programmingSubmission, buildTimingInfo); + + this.startResultWaitingTimer(submissionParticipationId); + }), + ) + .subscribe(); + } + this.submissionProcessingTopicsSubscribed.set(participationId, newSubmissionTopic); + } + } + + private isNewestSubmission(newSubmission: SubmissionProcessingDTO, exerciseId: number, participationId: number): boolean { + const currentSubmission = this.exerciseBuildState[exerciseId]?.[participationId]?.submission; + + if (!currentSubmission?.submissionDate) return true; + if (!newSubmission?.submissionDate) return false; + + return dayjs(newSubmission.submissionDate).isSameOrAfter(dayjs(currentSubmission.submissionDate)); + } + + private getSubmissionByCommitHash(submissionProcessing: SubmissionProcessingDTO): ProgrammingSubmission | undefined { + if (submissionProcessing.exerciseId && submissionProcessing.participationId && submissionProcessing.commitHash) { + const submission = this.exerciseBuildState[submissionProcessing.exerciseId]?.[submissionProcessing.participationId]?.submission; + if (submission && submission.commitHash === submissionProcessing.commitHash) { + return submission; + } + } + return undefined; + } + /** * Waits for a new result to come in while a pending submission exists. * Will stop waiting after the timer subject has emitted a value. @@ -264,6 +425,7 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi filter(() => !!this.exerciseBuildState[exerciseId][participationId]), tap(() => { // We reset the timer when a new result came through OR the timer ran out. The stream will then be inactive until the next submission comes in. + this.resetQueueEstimateTimer(participationId); this.resetResultWaitingTimer(participationId); }), ) @@ -283,8 +445,13 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi this.notifySubscribers(participationId, exerciseId, newSubmissionState); } - private emitBuildingSubmission(participationId: number, exerciseId: number, submission: ProgrammingSubmission) { - const newSubmissionState = { participationId, submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission }; + private emitBuildingSubmission(participationId: number, exerciseId: number, submission: ProgrammingSubmission, buildTimingInfo?: BuildTimingInfo) { + const newSubmissionState = { participationId, submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission, buildTimingInfo }; + this.notifySubscribers(participationId, exerciseId, newSubmissionState); + } + + private emitQueuedSubmission(participationId: number, exerciseId: number, submission: ProgrammingSubmission) { + const newSubmissionState = { participationId, submissionState: ProgrammingSubmissionState.IS_QUEUED, submission }; this.notifySubscribers(participationId, exerciseId, newSubmissionState); } @@ -329,6 +496,10 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi return this.currentExpectedResultETA - (Date.now() - Date.parse(submission.submissionDate as any)); } + private getExpectedRemainingTimeForQueue(submission: ProgrammingSubmission): number { + return this.currentExpectedQueueEstimate - (Date.now() - Date.parse(submission.submissionDate as any)); + } + /** * Initialize the cache from outside the service. * @@ -537,15 +708,37 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi // The new submission would then override the current latest pending submission. tap(() => { this.setupWebsocketSubscriptionForLatestPendingSubmission(participationId, exerciseId, personal); + if (this.isLocalCIProfile) { + this.setupWebsocketSubscriptionForSubmissionProcessing(participationId, exerciseId, personal); + } }), // Find out in what state the latest submission is (pending / failed). If the submission is pending, start the result timer. map((submission: ProgrammingSubmission | undefined) => { if (submission) { - const remainingTime = this.getExpectedRemainingTimeForBuild(submission); - if (remainingTime > 0) { - this.emitBuildingSubmission(participationId, exerciseId, submission); - this.startResultWaitingTimer(participationId, remainingTime); - return { participationId, submission: submissionToBeProcessed, submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION }; + if (this.isLocalCIProfile && submission.isProcessing === false && !this.didSubmissionStartProcessing(submission.commitHash!)) { + const queueRemainingTime = this.getExpectedRemainingTimeForQueue(submission); + if (queueRemainingTime > 0) { + this.emitQueuedSubmission(participationId, exerciseId, submission); + this.startQueueEstimateTimer(participationId, exerciseId, submission, queueRemainingTime); + return { + participationId, + submission: submissionToBeProcessed, + submissionState: ProgrammingSubmissionState.IS_QUEUED, + }; + } + } else { + let buildTimingInfo: BuildTimingInfo | undefined = { + estimatedCompletionDate: submission.estimatedCompletionDate, + buildStartDate: submission.buildStartDate, + }; + buildTimingInfo = buildTimingInfo ?? this.startedProcessingCache.get(submission.commitHash!); + this.removeSubmissionFromProcessingCache(submission.commitHash!); + const remainingTime = this.getExpectedRemainingTimeForBuild(submission); + if (remainingTime > 0) { + this.emitBuildingSubmission(participationId, exerciseId, submission, buildTimingInfo); + this.startResultWaitingTimer(participationId, remainingTime); + return { participationId, submission: submissionToBeProcessed, submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION }; + } } // The server sends the latest submission without a result - so it could be that the result is too old. In this case the error is shown directly. this.emitFailedSubmission(participationId, exerciseId); @@ -554,7 +747,7 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi this.emitNoPendingSubmission(participationId, exerciseId); return { participationId, submission: undefined, submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION }; }), - // Now update the exercise build state object and start the result subscription regardless of the submission state. + // Now update the exercise build state object and start the build and result subscription regardless of the submission state. tap((submissionStateObj: ProgrammingSubmissionStateObj) => { const exerciseSubmissionState: ExerciseSubmissionState = { ...(this.exerciseBuildState[exerciseId] || {}), [participationId]: submissionStateObj }; this.exerciseBuildState = { ...this.exerciseBuildState, [exerciseId]: exerciseSubmissionState }; @@ -575,6 +768,16 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi return { ...acc, [participationId]: { participationId, submissionState, submission } }; } + private didSubmissionStartProcessing(commitHash: string): boolean { + return !!this.startedProcessingCache.get(commitHash); + } + + private removeSubmissionFromProcessingCache(commitHash: string): void { + if (this.startedProcessingCache.has(commitHash)) { + this.startedProcessingCache.delete(commitHash); + } + } + /** * Returns programming submissions for exercise from the server * @param exerciseId the id of the exercise @@ -655,9 +858,13 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi this.resultSubscriptions = {}; Object.values(this.resultTimerSubscriptions).forEach((sub) => sub.unsubscribe()); this.resultTimerSubscriptions = {}; + Object.values(this.queueEstimateTimerSubscriptions).forEach((sub) => sub.unsubscribe()); + this.queueEstimateTimerSubscriptions = {}; this.submissionTopicsSubscribed.forEach((topic) => this.websocketService.unsubscribe(topic)); this.submissionTopicsSubscribed.forEach((_, participationId) => this.participationWebsocketService.unsubscribeForLatestResultOfParticipation(participationId, exercise)); this.submissionTopicsSubscribed.clear(); + this.submissionProcessingTopicsSubscribed.forEach((topic) => this.websocketService.unsubscribe(topic)); + this.submissionProcessingTopicsSubscribed.clear(); this.submissionSubjects = {}; this.exerciseBuildStateSubjects.delete(exercise.id!); } @@ -679,5 +886,31 @@ export class ProgrammingSubmissionService implements IProgrammingSubmissionServi this.websocketService.unsubscribe(submissionTopic); } } + const submissionProcessingTopic = this.submissionProcessingTopicsSubscribed.get(participationId); + if (submissionProcessingTopic) { + this.submissionProcessingTopicsSubscribed.delete(participationId); + + const openSubscriptionsForTopic = [...this.submissionProcessingTopicsSubscribed.values()].filter((topic: string) => topic === submissionProcessingTopic).length; + // Only unsubscribe if no other participations are using this topic + const isParcitipationUsingTopic = openSubscriptionsForTopic !== 0; + if (!isParcitipationUsingTopic) { + this.websocketService.unsubscribe(submissionProcessingTopic); + } + } + } + + /** + * Set the local CI profile to determine which build system is used. Used to set the state in tests. + * @param isLocalCIProfile + */ + public setLocalCIProfile(isLocalCIProfile: boolean) { + this.isLocalCIProfile = isLocalCIProfile; + } + + /** + * Get the local CI profile to determine which build system is used. + */ + public getIsLocalCIProfile() { + return this.isLocalCIProfile; } } diff --git a/src/main/webapp/app/exercises/shared/exercise-headers/exercise-headers-information/exercise-headers-information.component.html b/src/main/webapp/app/exercises/shared/exercise-headers/exercise-headers-information/exercise-headers-information.component.html index 30e26e951231..9a675274ad5f 100644 --- a/src/main/webapp/app/exercises/shared/exercise-headers/exercise-headers-information/exercise-headers-information.component.html +++ b/src/main/webapp/app/exercises/shared/exercise-headers/exercise-headers-information/exercise-headers-information.component.html @@ -30,6 +30,7 @@ [triggerLastGraded]="false" [showCompletion]="false" [showBadge]="false" + [showProgressBar]="true" /> } } diff --git a/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.html b/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.html new file mode 100644 index 000000000000..e3fad922db2d --- /dev/null +++ b/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.html @@ -0,0 +1,35 @@ +
+
+ + @if (isQueued()) { + + } @else { + + } + @if (estimatedRemaining() && estimatedRemaining() > 0) { + {{ estimatedRemaining() | artemisDurationFromSeconds }} + } +
+
+
+
+
+
+
+
+
+
diff --git a/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.scss b/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.scss new file mode 100644 index 000000000000..492978e093cc --- /dev/null +++ b/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.scss @@ -0,0 +1,8 @@ +.no-transition { + transition: opacity 0.5s !important; +} + +.mw-custom { + max-width: fit-content; + min-width: 230px; +} diff --git a/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.ts b/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.ts new file mode 100644 index 000000000000..b96b89855f00 --- /dev/null +++ b/src/main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component.ts @@ -0,0 +1,126 @@ +import { Component, OnDestroy, effect, input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'; + +@Component({ + selector: 'jhi-result-progress-bar', + standalone: true, + imports: [CommonModule, ArtemisSharedCommonModule], + templateUrl: './result-progress-bar.component.html', + styleUrl: './result-progress-bar.component.scss', +}) +export class ResultProgressBarComponent implements OnDestroy { + estimatedRemaining = input(0); + estimatedDuration = input(0); + isBuilding = input.required(); + isQueued = input.required(); + showBorder = input(false); + + isQueueProgressBarAnimated: boolean; + queueProgressBarOpacity: number; + queueProgressBarValue: number; + + isBuildProgressBarAnimated: boolean; + buildProgressBarOpacity: number; + buildProgressBarValue: number; + + estimatedDurationInterval: ReturnType | undefined; + + protected readonly faCircleNotch = faCircleNotch; + + constructor() { + effect(() => { + const isBuildingOrQueued = this.cleanUpIfNotBuildingOrQueued(); + if (!isBuildingOrQueued) { + return; + } + + clearInterval(this.estimatedDurationInterval); + this.updateProgressBarState(); + }); + } + + private updateProgressBarState() { + if (this.estimatedDuration() && this.estimatedRemaining()) { + if (this.isBuilding()) { + this.setupQueueProgressBarForBuild(); + this.updateBuildProgressBar(); + } else if (this.isQueued()) { + this.setupBuildProgressBarForQueued(); + this.updateQueueProgressBar(); + } + } else { + if (this.isBuilding()) { + this.setupQueueProgressBarForBuild(); + this.isBuildProgressBarAnimated = false; + this.buildProgressBarValue = 100; + } else if (this.isQueued()) { + this.setupBuildProgressBarForQueued(); + this.isQueueProgressBarAnimated = false; + this.queueProgressBarValue = 100; + } + this.estimatedDurationInterval = setInterval(() => { + this.alternateOpacity(this.isQueued()); + }, 1000); // 1 second + } + } + + private cleanUpIfNotBuildingOrQueued() { + const isBuildingOrQueued = true; + if (!this.isBuilding() && !this.isQueued()) { + if (this.estimatedDurationInterval) { + clearInterval(this.estimatedDurationInterval); + this.estimatedDurationInterval = undefined; + } + this.isQueueProgressBarAnimated = false; + } + return isBuildingOrQueued; + } + + ngOnDestroy() { + if (this.estimatedDurationInterval) { + clearInterval(this.estimatedDurationInterval); + } + } + + private setupBuildProgressBarForQueued() { + this.isBuildProgressBarAnimated = true; + this.buildProgressBarOpacity = 1; + this.buildProgressBarValue = 0; + } + + private setupQueueProgressBarForBuild() { + this.isQueueProgressBarAnimated = true; + this.queueProgressBarOpacity = 1; + this.queueProgressBarValue = 100; + } + + private updateQueueProgressBar() { + this.isQueueProgressBarAnimated = true; + this.queueProgressBarOpacity = 1; + if (this.estimatedDuration() === 0) { + this.queueProgressBarValue = 100; + return; + } + this.queueProgressBarValue = Math.round((1 - this.estimatedRemaining() / this.estimatedDuration()) * 100); + } + + private updateBuildProgressBar() { + this.isBuildProgressBarAnimated = true; + this.buildProgressBarOpacity = 1; + if (this.estimatedDuration() === 0) { + this.buildProgressBarValue = 100; + return; + } + this.buildProgressBarValue = Math.round((1 - this.estimatedRemaining() / this.estimatedDuration()) * 100); + } + + private alternateOpacity(isQueue?: boolean) { + if (isQueue) { + this.queueProgressBarOpacity = this.queueProgressBarOpacity === 1 ? 0 : 1; + } else { + this.buildProgressBarOpacity = this.buildProgressBarOpacity === 1 ? 0 : 1; + } + } +} diff --git a/src/main/webapp/app/exercises/shared/result/result.component.html b/src/main/webapp/app/exercises/shared/result/result.component.html index 01207f3c5052..7140b4c52e31 100644 --- a/src/main/webapp/app/exercises/shared/result/result.component.html +++ b/src/main/webapp/app/exercises/shared/result/result.component.html @@ -4,11 +4,45 @@ --> @switch (templateStatus) { + @case (ResultTemplateStatus.IS_QUEUED) { + @if (showProgressBar) { + + + } @else { + + + + @if (estimatedRemaining) { + {{ estimatedRemaining | artemisDurationFromSeconds }} + } + + } + } @case (ResultTemplateStatus.IS_BUILDING) { - - - - + @if (showProgressBar) { + + + } @else { + + + + @if (estimatedRemaining) { + {{ estimatedRemaining | artemisDurationFromSeconds }} + } + + } } @case (ResultTemplateStatus.FEEDBACK_GENERATION_FAILED) { @if (result) { diff --git a/src/main/webapp/app/exercises/shared/result/result.component.ts b/src/main/webapp/app/exercises/shared/result/result.component.ts index 82613884b7cc..8a682b4a4c36 100644 --- a/src/main/webapp/app/exercises/shared/result/result.component.ts +++ b/src/main/webapp/app/exercises/shared/result/result.component.ts @@ -55,6 +55,7 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { @Input() participation: Participation; @Input() isBuilding: boolean; + @Input() isQueued = false; @Input() short = true; @Input() result?: Result; @Input() showUngradedResults = false; @@ -64,6 +65,10 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { @Input() showCompletion = true; @Input() missingResultInfo = MissingResultInformation.NONE; @Input() exercise?: Exercise; + @Input() estimatedCompletionDate?: dayjs.Dayjs; + @Input() buildStartDate?: dayjs.Dayjs; + @Input() showProgressBar = false; + @Input() showProgressBarBorder = false; textColorClass: string; resultIconClass: IconProp; @@ -74,6 +79,10 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { resultTooltip?: string; latestDueDate: dayjs.Dayjs | undefined; + estimatedDurationInterval?: ReturnType; + estimatedRemaining: number = 0; + estimatedDuration: number = 0; + // Icons readonly faCircleNotch = faCircleNotch; readonly faFile = faFile; @@ -163,6 +172,9 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { if (this.resultUpdateSubscription) { clearTimeout(this.resultUpdateSubscription); } + if (this.estimatedDurationInterval) { + clearInterval(this.estimatedDurationInterval); + } } /** @@ -175,24 +187,34 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { this.ngOnInit(); } - if (changes.isBuilding?.currentValue) { + if (changes.isBuilding?.currentValue && changes.isBuilding?.currentValue === true) { // If it's building, we change the templateStatus to building regardless of any other settings. this.templateStatus = ResultTemplateStatus.IS_BUILDING; + } else if (changes.isQueued?.currentValue && changes.isQueued?.currentValue === true) { + // If it's queued, we change the templateStatus to queued regardless of any other settings. + this.templateStatus = ResultTemplateStatus.IS_QUEUED; } else if (changes.missingResultInfo || changes.isBuilding?.previousValue) { // If ... // ... the result was building and is not building anymore, or // ... the missingResultInfo changed // we evaluate the result status. - this.evaluate(); } + + clearInterval(this.estimatedDurationInterval); + if (this.estimatedCompletionDate && this.buildStartDate) { + this.estimatedDurationInterval = setInterval(() => { + this.estimatedRemaining = Math.max(0, dayjs(this.estimatedCompletionDate).diff(dayjs(), 'seconds')); + this.estimatedDuration = dayjs(this.estimatedCompletionDate).diff(dayjs(this.buildStartDate), 'seconds'); + }); + } } /** * Sets the corresponding icon, styling and message to display results. */ evaluate() { - this.templateStatus = evaluateTemplateStatus(this.exercise, this.participation, this.result, this.isBuilding, this.missingResultInfo); + this.templateStatus = evaluateTemplateStatus(this.exercise, this.participation, this.result, this.isBuilding, this.missingResultInfo, this.isQueued); if (this.templateStatus === ResultTemplateStatus.LATE) { this.textColorClass = getTextColorClass(this.result, this.templateStatus); this.resultIconClass = getResultIconClass(this.result, this.templateStatus); diff --git a/src/main/webapp/app/exercises/shared/result/result.utils.ts b/src/main/webapp/app/exercises/shared/result/result.utils.ts index 39541661fb38..c77cc3a9fcf2 100644 --- a/src/main/webapp/app/exercises/shared/result/result.utils.ts +++ b/src/main/webapp/app/exercises/shared/result/result.utils.ts @@ -29,6 +29,11 @@ export enum ResultTemplateStatus { * This is currently only relevant for programming exercises. */ IS_BUILDING = 'IS_BUILDING', + /** + * Submission is currently queued and will be processed soon. + * This is currently only relevant for programming exercises. + */ + IS_QUEUED = 'IS_QUEUED', /** * An automatic feedback suggestion is currently being generated and should be available soon. * This is currently only relevant for programming exercises. @@ -154,6 +159,7 @@ export const evaluateTemplateStatus = ( result: Result | undefined, isBuilding: boolean, missingResultInfo = MissingResultInformation.NONE, + isQueued = false, ): ResultTemplateStatus => { // Fallback if participation is not set if (!participation || !exercise) { @@ -216,7 +222,9 @@ export const evaluateTemplateStatus = ( // Evaluate status for programming and quiz exercises if (isProgrammingOrQuiz(participation)) { - if (isBuilding) { + if (isQueued) { + return ResultTemplateStatus.IS_QUEUED; + } else if (isBuilding) { return ResultTemplateStatus.IS_BUILDING; } else if (isAIResultAndIsBeingProcessed(result)) { return ResultTemplateStatus.IS_GENERATING_FEEDBACK; diff --git a/src/main/webapp/app/exercises/shared/result/updating-result.component.html b/src/main/webapp/app/exercises/shared/result/updating-result.component.html index b6998df616a5..cdd86d95b9a9 100644 --- a/src/main/webapp/app/exercises/shared/result/updating-result.component.html +++ b/src/main/webapp/app/exercises/shared/result/updating-result.component.html @@ -4,6 +4,7 @@ [result]="result" [participation]="participation" [isBuilding]="isBuilding" + [isQueued]="isQueued" [short]="short" [showUngradedResults]="showUngradedResults" [showBadge]="showBadge" @@ -11,4 +12,8 @@ [missingResultInfo]="missingResultInfo" [isInSidebarCard]="isInSidebarCard" [showCompletion]="showCompletion" + [estimatedCompletionDate]="estimatedCompletionDate" + [buildStartDate]="buildStartDate" + [showProgressBar]="showProgressBarInResult" + [showProgressBarBorder]="showProgressBarBorder" /> diff --git a/src/main/webapp/app/exercises/shared/result/updating-result.component.ts b/src/main/webapp/app/exercises/shared/result/updating-result.component.ts index ac2f1cc9ae4d..2c38013e6a11 100644 --- a/src/main/webapp/app/exercises/shared/result/updating-result.component.ts +++ b/src/main/webapp/app/exercises/shared/result/updating-result.component.ts @@ -4,7 +4,7 @@ import { filter, map, tap } from 'rxjs/operators'; import { ParticipationWebsocketService } from 'app/overview/participation-websocket.service'; import { RepositoryService } from 'app/exercises/shared/result/repository.service'; import dayjs from 'dayjs/esm'; -import { ProgrammingSubmissionService, ProgrammingSubmissionState } from 'app/exercises/programming/participate/programming-submission.service'; +import { BuildTimingInfo, ProgrammingSubmissionService, ProgrammingSubmissionState } from 'app/exercises/programming/participate/programming-submission.service'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model'; import { ResultService } from 'app/exercises/shared/result/result.service'; @@ -35,6 +35,8 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { @Input() showIcon = true; @Input() isInSidebarCard = false; @Input() showCompletion = true; + @Input() showProgressBar = false; + @Input() showProgressBarBorder = false; @Output() showResult = new EventEmitter(); /** * @property personalParticipation Whether the participation belongs to the user (by being a student) or not (by being an instructor) @@ -45,6 +47,10 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { result?: Result; isBuilding: boolean; + isQueued: boolean; + estimatedCompletionDate?: dayjs.Dayjs; + buildStartDate?: dayjs.Dayjs; + showProgressBarInResult = false; missingResultInfo = MissingResultInformation.NONE; public resultSubscription: Subscription; public submissionSubscription: Subscription; @@ -69,6 +75,10 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { this.subscribeForNewSubmissions(); } + if (this.submissionService.getIsLocalCIProfile()) { + this.showProgressBarInResult = this.showProgressBar; + } + if (this.result) { this.showResult.emit(); } @@ -134,7 +144,7 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { .getLatestPendingSubmissionByParticipationId(this.participation.id!, this.exercise.id!, this.personalParticipation) .pipe( filter(({ submission }) => this.shouldUpdateSubmissionState(submission)), - tap(({ submissionState }) => this.updateSubmissionState(submissionState)), + tap(({ submissionState, buildTimingInfo, submission }) => this.updateSubmissionState(submissionState, buildTimingInfo, submission?.submissionDate)), ) .subscribe(); } @@ -169,10 +179,17 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { * Updates the shown status based on the given state of a submission. * * @param submissionState the submission is currently in. + * @param buildTimingInfo object container the build start time and the estimated completion time. + * @param submissionDate the date when the submission was created. */ - private updateSubmissionState(submissionState: ProgrammingSubmissionState) { + private updateSubmissionState(submissionState: ProgrammingSubmissionState, buildTimingInfo?: BuildTimingInfo, submissionDate?: dayjs.Dayjs) { + this.isQueued = submissionState === ProgrammingSubmissionState.IS_QUEUED; this.isBuilding = submissionState === ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION; + if (this.submissionService.getIsLocalCIProfile()) { + this.updateBuildTimingInfo(submissionState, buildTimingInfo, submissionDate); + } + if (submissionState === ProgrammingSubmissionState.HAS_FAILED_SUBMISSION) { this.missingResultInfo = this.generateMissingResultInfoForFailedProgrammingExerciseSubmission(); } else { @@ -180,4 +197,32 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { this.missingResultInfo = MissingResultInformation.NONE; } } + + /** + * Updates the build timing information based on the submission state. + * + * @param submissionState - The current state of the submission. + * @param [buildTimingInfo] - Optional object containing the build start time and the estimated completion time. + * @param [submissionDate] - Optional date when the submission was created. + */ + private updateBuildTimingInfo(submissionState: ProgrammingSubmissionState, buildTimingInfo?: BuildTimingInfo, submissionDate?: dayjs.Dayjs) { + if (submissionState === ProgrammingSubmissionState.IS_QUEUED) { + this.submissionService.fetchQueueReleaseDateEstimationByParticipationId(this.participation.id!).subscribe((releaseDate) => { + if (releaseDate && !this.isBuilding) { + this.estimatedCompletionDate = releaseDate; + this.buildStartDate = submissionDate; + } + }); + } else if ( + submissionState === ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION && + buildTimingInfo && + dayjs(buildTimingInfo?.estimatedCompletionDate).isAfter(dayjs()) + ) { + this.estimatedCompletionDate = buildTimingInfo?.estimatedCompletionDate; + this.buildStartDate = buildTimingInfo?.buildStartDate; + } else { + this.estimatedCompletionDate = undefined; + this.buildStartDate = undefined; + } + } } diff --git a/src/main/webapp/app/overview/submission-result-status.component.html b/src/main/webapp/app/overview/submission-result-status.component.html index 062fe40125fa..074f1f2d8778 100644 --- a/src/main/webapp/app/overview/submission-result-status.component.html +++ b/src/main/webapp/app/overview/submission-result-status.component.html @@ -14,6 +14,7 @@ [showCompletion]="showCompletion" [short]="short" [personalParticipation]="true" + [showProgressBar]="showProgressBar" /> } @else { diff --git a/src/main/webapp/app/overview/submission-result-status.component.ts b/src/main/webapp/app/overview/submission-result-status.component.ts index 2677fcf51883..78fc9b3a0a4d 100644 --- a/src/main/webapp/app/overview/submission-result-status.component.ts +++ b/src/main/webapp/app/overview/submission-result-status.component.ts @@ -34,6 +34,7 @@ export class SubmissionResultStatusComponent implements OnChanges { @Input() showCompletion = true; @Input() short = true; @Input() triggerLastGraded = true; + @Input() showProgressBar = false; quizNotStarted: boolean; exerciseMissedDueDate: boolean; diff --git a/src/main/webapp/app/overview/submission-result-status.module.ts b/src/main/webapp/app/overview/submission-result-status.module.ts index b1e04f09fc70..eb16329d6fc5 100644 --- a/src/main/webapp/app/overview/submission-result-status.module.ts +++ b/src/main/webapp/app/overview/submission-result-status.module.ts @@ -4,9 +4,11 @@ import { SubmissionResultStatusComponent } from 'app/overview/submission-result- import { UpdatingResultComponent } from 'app/exercises/shared/result/updating-result.component'; import { ArtemisProgrammingExerciseActionsModule } from 'app/exercises/programming/shared/actions/programming-exercise-actions.module'; import { ResultComponent } from 'app/exercises/shared/result/result.component'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; +import { ResultProgressBarComponent } from 'app/exercises/shared/result/result-progress-bar/result-progress-bar.component'; @NgModule({ - imports: [ArtemisSharedModule, ArtemisProgrammingExerciseActionsModule], + imports: [ArtemisSharedModule, ArtemisProgrammingExerciseActionsModule, ArtemisSharedComponentModule, ResultProgressBarComponent], declarations: [SubmissionResultStatusComponent, UpdatingResultComponent, ResultComponent], exports: [SubmissionResultStatusComponent, UpdatingResultComponent, ResultComponent], }) diff --git a/src/main/webapp/i18n/de/editor.json b/src/main/webapp/i18n/de/editor.json index abd74a5c2b5f..bda2a2602317 100644 --- a/src/main/webapp/i18n/de/editor.json +++ b/src/main/webapp/i18n/de/editor.json @@ -31,6 +31,8 @@ "submitDescription": "Staged, committed, pushed, kompiliert und testet Deine Änderungen.", "buildOutput": "Build Ergebnisse", "building": "Build und Tests werden ausgeführt...", + "queued": "In Warteschlange...", + "toolTip": "Abgaben werden zuerst in die Warteschlange gestellt und dann verarbeitet. Die geschätzte Zeit für jeden Schritt wird angezeigt. Bitte beachte, dass dies nur eine Schätzung ist und variieren kann.", "buildFailed": "Build gescheitert", "noBuildOutput": "Keine Build-Ergebnisse verfügbar.", "generatingFeedback": "Feedback wird generiert...", diff --git a/src/main/webapp/i18n/en/editor.json b/src/main/webapp/i18n/en/editor.json index 18442db3ca14..123f7231e98c 100644 --- a/src/main/webapp/i18n/en/editor.json +++ b/src/main/webapp/i18n/en/editor.json @@ -31,6 +31,8 @@ "submitDescription": "Stage, commit, push, build and test your changes.", "buildOutput": "  Build Output", "building": "Building and testing...", + "queued": "Queued...", + "toolTip": "Submissions are first queued and then processed. The estimated time for each step is displayed. Please note that this is an estimate and may vary.", "buildFailed": "Build failed", "noBuildOutput": "No build results available", "generatingFeedback": "Generating feedback...", diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java index cdc242273761..e3979dc830fb 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java @@ -4,6 +4,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.LOCALCI_WORKING_DIRECTORY; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.within; import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.argThat; @@ -21,6 +22,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -53,12 +55,16 @@ import com.hazelcast.map.IMap; import de.tum.cit.aet.artemis.assessment.domain.Result; +import de.tum.cit.aet.artemis.buildagent.dto.BuildConfig; import de.tum.cit.aet.artemis.buildagent.dto.BuildJobQueueItem; +import de.tum.cit.aet.artemis.buildagent.dto.JobTimingInfo; import de.tum.cit.aet.artemis.buildagent.dto.ResultBuildJob; import de.tum.cit.aet.artemis.core.exception.VersionControlException; import de.tum.cit.aet.artemis.exercise.domain.ExerciseMode; import de.tum.cit.aet.artemis.exercise.domain.Team; +import de.tum.cit.aet.artemis.exercise.dto.SubmissionDTO; import de.tum.cit.aet.artemis.programming.AbstractProgrammingIntegrationLocalCILocalVCTestBase; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseBuildStatistics; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingSubmission; import de.tum.cit.aet.artemis.programming.domain.RepositoryType; @@ -92,6 +98,10 @@ protected String getTestPrefix() { private String commitHash; + private IQueue queuedJobs; + + private IMap processingJobs; + @BeforeAll void setupAll() { CredentialsProvider.setDefault(new UsernamePasswordCredentialsProvider(localVCUsername, localVCPassword)); @@ -121,6 +131,9 @@ void initRepositories() throws Exception { Map.of("commitHash", commitHash), Map.of("commitHash", commitHash)); localVCLocalCITestService.mockInspectImage(dockerClient); + + queuedJobs = hazelcastInstance.getQueue("buildJobQueue"); + processingJobs = hazelcastInstance.getMap("processingJobs"); } @AfterEach @@ -517,4 +530,55 @@ void testPauseAndResumeBuildAgent() { hazelcastInstance.getTopic("resumeBuildAgentTopic").publish(buildAgentName); localVCLocalCITestService.testLatestSubmission(studentParticipation.getId(), commitHash, 1, false); } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testBuildJobTimingInfo() { + // Pause build agent processing + sharedQueueProcessingService.removeListenerAndCancelScheduledFuture(); + ProgrammingExerciseBuildStatistics buildStatistics = new ProgrammingExerciseBuildStatistics(programmingExercise.getId(), 20, 100); + programmingExerciseBuildStatisticsRepository.save(buildStatistics); + + ProgrammingExerciseStudentParticipation studentParticipation = localVCLocalCITestService.createParticipation(programmingExercise, student1Login); + + localVCServletService.processNewPush(commitHash, studentAssignmentRepository.originGit.getRepository()); + + await().until(() -> queuedJobs.stream().anyMatch(buildJobQueueItem -> buildJobQueueItem.buildConfig().commitHashToBuild().equals(commitHash) + && buildJobQueueItem.participationId() == studentParticipation.getId())); + + BuildJobQueueItem item = queuedJobs.stream().filter(i -> i.buildConfig().commitHashToBuild().equals(commitHash) && i.participationId() == studentParticipation.getId()) + .findFirst().orElseThrow(); + assertThat(item.jobTimingInfo().estimatedDuration()).isEqualTo(24); + sharedQueueProcessingService.init(); + + await().until(() -> processingJobs.values().stream().anyMatch(buildJobQueueItem -> buildJobQueueItem.buildConfig().commitHashToBuild().equals(commitHash) + && buildJobQueueItem.participationId() == studentParticipation.getId())); + item = processingJobs.values().stream().filter(i -> i.buildConfig().commitHashToBuild().equals(commitHash) && i.participationId() == studentParticipation.getId()) + .findFirst().orElseThrow(); + assertThat(item.jobTimingInfo().estimatedDuration()).isEqualTo(24); + assertThat(item.jobTimingInfo().estimatedCompletionDate()).isCloseTo(item.jobTimingInfo().buildStartDate().plusSeconds(24), within(500, ChronoUnit.MILLIS)); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetSubmissionReturnsWhenSubmissionProcessing() throws Exception { + ProgrammingSubmission submission = (ProgrammingSubmission) new ProgrammingSubmission().submissionDate(ZonedDateTime.now().minusSeconds(61L)); + submission.setCommitHash(commitHash); + submission = programmingExerciseUtilService.addProgrammingSubmission(programmingExercise, submission, TEST_PREFIX + "student1"); + + JobTimingInfo jobTimingInfo = new JobTimingInfo(ZonedDateTime.now().minusSeconds(30), ZonedDateTime.now(), null, ZonedDateTime.now().plusSeconds(30), 60); + BuildConfig buildConfig = new BuildConfig(null, null, commitHash, commitHash, null, null, null, null, false, false, false, null, 0, null, null, null, null); + BuildJobQueueItem buildJobQueueItem = new BuildJobQueueItem("1", "1", null, submission.getParticipation().getId(), 1L, programmingExercise.getId(), 0, 1, null, null, + jobTimingInfo, buildConfig, null); + + processingJobs.put(buildJobQueueItem.id(), buildJobQueueItem); + var submissionDto = request.get("/api/programming-exercise-participations/" + submission.getParticipation().getId() + "/latest-pending-submission", HttpStatus.OK, + SubmissionDTO.class); + processingJobs.delete(buildJobQueueItem.id()); + + assertThat(submissionDto).isNotNull(); + assertThat(submissionDto.isProcessing()).isTrue(); + assertThat(submissionDto.buildStartDate()).isNotNull(); + assertThat(submissionDto.estimatedCompletionDate()).isNotNull(); + } } diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java index 90622c6f808c..8ab4e99eb38e 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java @@ -1,6 +1,7 @@ package de.tum.cit.aet.artemis.programming.icl; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; import static org.awaitility.Awaitility.await; import java.net.URLEncoder; @@ -8,6 +9,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; @@ -82,9 +84,9 @@ void createJobs() { // temporarily remove listener to avoid triggering build job processing sharedQueueProcessingService.removeListenerAndCancelScheduledFuture(); - JobTimingInfo jobTimingInfo1 = new JobTimingInfo(ZonedDateTime.now().plusMinutes(1), ZonedDateTime.now().plusMinutes(2), ZonedDateTime.now().plusMinutes(3)); - JobTimingInfo jobTimingInfo2 = new JobTimingInfo(ZonedDateTime.now(), ZonedDateTime.now().plusMinutes(1), ZonedDateTime.now().plusMinutes(2)); - JobTimingInfo jobTimingInfo3 = new JobTimingInfo(ZonedDateTime.now().minusMinutes(10), ZonedDateTime.now().minusMinutes(9), ZonedDateTime.now().plusSeconds(150)); + JobTimingInfo jobTimingInfo1 = new JobTimingInfo(ZonedDateTime.now().plusMinutes(1), ZonedDateTime.now().plusMinutes(2), ZonedDateTime.now().plusMinutes(3), null, 20); + JobTimingInfo jobTimingInfo2 = new JobTimingInfo(ZonedDateTime.now(), ZonedDateTime.now().plusMinutes(1), ZonedDateTime.now().plusMinutes(2), null, 20); + JobTimingInfo jobTimingInfo3 = new JobTimingInfo(ZonedDateTime.now().minusMinutes(10), ZonedDateTime.now().minusMinutes(9), ZonedDateTime.now().plusSeconds(150), null, 20); BuildConfig buildConfig = new BuildConfig("echo 'test'", "test", "test", "test", "test", "test", null, null, false, false, false, null, 0, null, null, null, null); RepositoryInfo repositoryInfo = new RepositoryInfo("test", null, RepositoryType.USER, "test", "test", "test", null, null); @@ -93,8 +95,8 @@ void createJobs() { buildAgent = new BuildAgentDTO(buildAgentShortName, memberAddress, buildAgentDisplayName); job1 = new BuildJobQueueItem("1", "job1", buildAgent, 1, course.getId(), 1, 1, 1, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo1, buildConfig, null); - job2 = new BuildJobQueueItem("2", "job2", buildAgent, 2, course.getId(), 1, 1, 1, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo2, buildConfig, null); - agent1 = new BuildAgentInformation(buildAgent, 1, 0, new ArrayList<>(List.of(job1)), BuildAgentInformation.BuildAgentStatus.IDLE, new ArrayList<>(List.of(job2)), null); + job2 = new BuildJobQueueItem("2", "job2", buildAgent, 2, course.getId(), 1, 1, 2, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo2, buildConfig, null); + agent1 = new BuildAgentInformation(buildAgent, 2, 1, new ArrayList<>(List.of(job1)), BuildAgentInformation.BuildAgentStatus.IDLE, new ArrayList<>(List.of(job2)), null); BuildJobQueueItem finishedJobQueueItem1 = new BuildJobQueueItem("3", "job3", buildAgent, 3, course.getId(), 1, 1, 1, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo1, buildConfig, null); BuildJobQueueItem finishedJobQueueItem2 = new BuildJobQueueItem("4", "job4", buildAgent, 4, course.getId() + 1, 1, 1, 1, BuildStatus.FAILED, repositoryInfo, jobTimingInfo2, @@ -279,7 +281,7 @@ void testGetFinishedBuildJobs_returnsFilteredJobs() throws Exception { // Create a failed job to filter for JobTimingInfo jobTimingInfo = new JobTimingInfo(ZonedDateTime.now().plusDays(1), ZonedDateTime.now().plusDays(1).plusMinutes(2), - ZonedDateTime.now().plusDays(1).plusMinutes(10)); + ZonedDateTime.now().plusDays(1).plusMinutes(10), null, 0); BuildConfig buildConfig = new BuildConfig("echo 'test'", "test", "test", "test", "test", "test", null, null, false, false, false, null, 0, null, null, null, null); RepositoryInfo repositoryInfo = new RepositoryInfo("test", null, RepositoryType.USER, "test", "test", "test", null, null); var failedJob1 = new BuildJobQueueItem("5", "job5", buildAgent, 1, course.getId(), 1, 1, 1, BuildStatus.FAILED, repositoryInfo, jobTimingInfo, buildConfig, null); @@ -389,4 +391,38 @@ void testPauseAllBuildAgents() throws Exception { return agents.stream().allMatch(agent -> agent.status() == BuildAgentInformation.BuildAgentStatus.IDLE); }); } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testBuildJob() throws Exception { + var now = ZonedDateTime.now(); + JobTimingInfo jobTimingInfo1 = new JobTimingInfo(now, now, null, now.plusSeconds(24), 24); + JobTimingInfo jobTimingInfo2 = new JobTimingInfo(now, now.plusSeconds(5), null, now.plusSeconds(29), 24); + JobTimingInfo jobTimingInfo3 = new JobTimingInfo(now.plusSeconds(1), null, null, null, 24); + JobTimingInfo jobTimingInfo4 = new JobTimingInfo(now.plusSeconds(2), null, null, null, 24); + JobTimingInfo jobTimingInfo5 = new JobTimingInfo(now.plusSeconds(3), null, null, null, 24); + + BuildConfig buildConfig = new BuildConfig("echo 'test'", "test", "test", "test", "test", "test", null, null, false, false, false, null, 0, null, null, null, null); + RepositoryInfo repositoryInfo = new RepositoryInfo("test", null, RepositoryType.USER, "test", "test", "test", null, null); + + var job1 = new BuildJobQueueItem("1", "job1", buildAgent, 1, course.getId(), 1, 1, 1, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo1, buildConfig, null); + var job2 = new BuildJobQueueItem("2", "job2", buildAgent, 2, course.getId(), 1, 1, 2, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo2, buildConfig, null); + var job3 = new BuildJobQueueItem("3", "job3", buildAgent, 2, course.getId(), 1, 1, 2, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo3, buildConfig, null); + var job4 = new BuildJobQueueItem("4", "job4", buildAgent, 2, course.getId(), 1, 1, 2, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo4, buildConfig, null); + var job5 = new BuildJobQueueItem("5", "job5", buildAgent, 2, course.getId(), 1, 1, 2, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo5, buildConfig, null); + + processingJobs.clear(); + processingJobs.put(job1.id(), job1); + processingJobs.put(job2.id(), job2); + queuedJobs.clear(); + queuedJobs.put(job3); + queuedJobs.put(job4); + queuedJobs.put(job5); + + agent1 = new BuildAgentInformation(buildAgent, 2, 2, new ArrayList<>(List.of(job1, job2)), BuildAgentInformation.BuildAgentStatus.ACTIVE, null, null); + buildAgentInformation.put(buildAgent.memberAddress(), agent1); + + var queueDurationEstimation = sharedQueueManagementService.getBuildJobEstimatedStartDate(job4.participationId()); + assertThat(queueDurationEstimation).isCloseTo(now.plusSeconds(48), within(1, ChronoUnit.SECONDS)); + } } diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIServiceTest.java index 9901886e5c42..ef4e012901da 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIServiceTest.java @@ -70,7 +70,7 @@ void testReturnCorrectBuildStatus() { ProgrammingExercise exercise = exerciseUtilService.getFirstExerciseWithType(course, ProgrammingExercise.class); ProgrammingExerciseStudentParticipation participation = participationUtilService.addStudentParticipationForProgrammingExercise(exercise, TEST_PREFIX + "student1"); - JobTimingInfo jobTimingInfo = new JobTimingInfo(ZonedDateTime.now(), ZonedDateTime.now().plusMinutes(1), ZonedDateTime.now().plusMinutes(2)); + JobTimingInfo jobTimingInfo = new JobTimingInfo(ZonedDateTime.now(), ZonedDateTime.now().plusMinutes(1), ZonedDateTime.now().plusMinutes(2), null, 0); BuildConfig buildConfig = new BuildConfig("echo 'test'", "test", "test", "test", "test", "test", null, null, false, false, false, null, 0, null, null, null, null); RepositoryInfo repositoryInfo = new RepositoryInfo("test", null, RepositoryType.USER, "test", "test", "test", null, null); diff --git a/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java b/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java index f0dfa2fe983c..cbdd0bcc9cf2 100644 --- a/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractSpringIntegrationLocalCILocalVCTest.java @@ -52,6 +52,7 @@ import de.tum.cit.aet.artemis.programming.icl.LocalVCLocalCITestService; import de.tum.cit.aet.artemis.programming.icl.TestBuildAgentConfiguration; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildConfigRepository; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildStatisticsRepository; import de.tum.cit.aet.artemis.programming.repository.SolutionProgrammingExerciseParticipationRepository; import de.tum.cit.aet.artemis.programming.repository.TemplateProgrammingExerciseParticipationRepository; import de.tum.cit.aet.artemis.programming.service.ProgrammingMessagingService; @@ -104,6 +105,9 @@ public abstract class AbstractSpringIntegrationLocalCILocalVCTest extends Abstra @Autowired protected ProgrammingExerciseBuildConfigRepository programmingExerciseBuildConfigRepository; + @Autowired + protected ProgrammingExerciseBuildStatisticsRepository programmingExerciseBuildStatisticsRepository; + @Autowired protected TemplateProgrammingExerciseParticipationRepository templateProgrammingExerciseParticipationRepository; diff --git a/src/test/javascript/spec/component/shared/result-progress-bar.component.spec.ts b/src/test/javascript/spec/component/shared/result-progress-bar.component.spec.ts new file mode 100644 index 000000000000..b4d97307071d --- /dev/null +++ b/src/test/javascript/spec/component/shared/result-progress-bar.component.spec.ts @@ -0,0 +1,112 @@ +import { ComponentFixture, TestBed, fakeAsync } from '@angular/core/testing'; +import { ResultProgressBarComponent } from '../../../../../main/webapp/app/exercises/shared/result/result-progress-bar/result-progress-bar.component'; +import { ArtemisTestModule } from '../../test.module'; + +describe('ResultProgressBarComponent', () => { + let component: ResultProgressBarComponent; + let fixture: ComponentFixture; + let clearIntervalSpy: jest.SpyInstance; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResultProgressBarComponent, ArtemisTestModule], + }).compileComponents(); + + fixture = TestBed.createComponent(ResultProgressBarComponent); + component = fixture.componentInstance; + + clearIntervalSpy = jest.spyOn(window, 'clearInterval'); + + fixture.componentRef.setInput('estimatedRemaining', 10); + fixture.componentRef.setInput('estimatedDuration', 20); + fixture.componentRef.setInput('isBuilding', false); + fixture.componentRef.setInput('isQueued', true); + + fixture.detectChanges(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('should clear interval when not queued or building', fakeAsync(() => { + fixture.componentRef.setInput('isBuilding', false); + fixture.componentRef.setInput('isQueued', false); + fixture.detectChanges(); + + expect(clearIntervalSpy).toHaveBeenCalledWith(component.estimatedDurationInterval); + })); + + it('should set queue progress bar', fakeAsync(() => { + jest.useFakeTimers(); + fixture.componentRef.setInput('estimatedDuration', 25); + fixture.detectChanges(); + + expect(component.isBuildProgressBarAnimated).toBeTrue(); + expect(component.buildProgressBarOpacity).toBe(1); + expect(component.buildProgressBarValue).toBe(0); + + jest.advanceTimersByTime(1500); + + expect(component.queueProgressBarValue).toBeGreaterThan(0); + expect(component.queueProgressBarValue).toBeLessThan(100); + })); + + it('should set build progress bar', fakeAsync(() => { + jest.useFakeTimers(); + fixture.componentRef.setInput('isBuilding', true); + fixture.componentRef.setInput('isQueued', false); + fixture.detectChanges(); + + expect(component.isQueueProgressBarAnimated).toBeTrue(); + expect(component.queueProgressBarOpacity).toBe(1); + expect(component.queueProgressBarValue).toBe(100); + + jest.advanceTimersByTime(1500); + + expect(component.buildProgressBarValue).toBeGreaterThan(0); + expect(component.buildProgressBarValue).toBeLessThan(100); + })); + + it('should alternate opacity when queued', fakeAsync(() => { + component.queueProgressBarOpacity = 1; + + jest.useFakeTimers(); + fixture.componentRef.setInput('isBuilding', false); + fixture.componentRef.setInput('isQueued', true); + fixture.componentRef.setInput('estimatedDuration', undefined); + fixture.componentRef.setInput('estimatedRemaining', undefined); + fixture.detectChanges(); + + expect(component.isQueueProgressBarAnimated).toBeFalse(); + expect(component.queueProgressBarValue).toBe(100); + + jest.advanceTimersByTime(1500); + + expect(component.queueProgressBarOpacity).toBe(0); + })); + + it('should alternate opacity when building', fakeAsync(() => { + component.buildProgressBarOpacity = 1; + + jest.useFakeTimers(); + fixture.componentRef.setInput('isBuilding', true); + fixture.componentRef.setInput('isQueued', false); + fixture.componentRef.setInput('estimatedDuration', undefined); + fixture.componentRef.setInput('estimatedRemaining', undefined); + fixture.detectChanges(); + + expect(component.isBuildProgressBarAnimated).toBeFalse(); + expect(component.buildProgressBarValue).toBe(100); + + jest.advanceTimersByTime(1500); + + expect(component.buildProgressBarOpacity).toBe(0); + })); + + it('should clear interval on destroy', fakeAsync(() => { + component.ngOnDestroy(); + expect(clearIntervalSpy).toHaveBeenCalledWith(component.estimatedDurationInterval); + })); +}); diff --git a/src/test/javascript/spec/component/shared/result.component.spec.ts b/src/test/javascript/spec/component/shared/result.component.spec.ts index 0ceee5f6a787..4bf9b19beb03 100644 --- a/src/test/javascript/spec/component/shared/result.component.spec.ts +++ b/src/test/javascript/spec/component/shared/result.component.spec.ts @@ -399,4 +399,20 @@ describe('ResultComponent', () => { expect(comp.templateStatus).toEqual(ResultTemplateStatus.HAS_RESULT); expect(comp.resultTooltip).toContain('artemisApp.result.resultString.automaticAIFeedbackSuccessfulTooltip'); }); + + it('should trigger Interval creation on estimatedCompletionDate change', () => { + jest.useFakeTimers(); + comp.buildStartDate = dayjs().subtract(20, 'seconds'); + comp.estimatedCompletionDate = dayjs().add(20, 'seconds'); + comp.ngOnChanges({}); + + jest.advanceTimersByTime(1200); + expect(comp.estimatedDurationInterval).toBeDefined(); + expect(comp.estimatedRemaining).toBeGreaterThan(0); + expect(comp.estimatedRemaining).toBeLessThan(40); + expect(comp.estimatedDuration).toBe(40); + + jest.clearAllTimers(); + jest.useRealTimers(); + }); }); diff --git a/src/test/javascript/spec/component/shared/updating-result.component.spec.ts b/src/test/javascript/spec/component/shared/updating-result.component.spec.ts index 9b8c8b9435e9..42294380996d 100644 --- a/src/test/javascript/spec/component/shared/updating-result.component.spec.ts +++ b/src/test/javascript/spec/component/shared/updating-result.component.spec.ts @@ -4,7 +4,12 @@ import { DebugElement } from '@angular/core'; import { BehaviorSubject, of } from 'rxjs'; import { ArtemisTestModule } from '../../test.module'; import { ParticipationWebsocketService } from 'app/overview/participation-websocket.service'; -import { ProgrammingSubmissionService, ProgrammingSubmissionState } from 'app/exercises/programming/participate/programming-submission.service'; +import { + BuildTimingInfo, + ProgrammingSubmissionService, + ProgrammingSubmissionState, + ProgrammingSubmissionStateObj, +} from 'app/exercises/programming/participate/programming-submission.service'; import { MockProgrammingSubmissionService } from '../../helpers/mocks/service/mock-programming-submission.service'; import { triggerChanges } from '../../helpers/utils/general.utils'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; @@ -28,6 +33,8 @@ describe('UpdatingResultComponent', () => { let subscribeForLatestResultOfParticipationSubject: BehaviorSubject; let getLatestPendingSubmissionStub: jest.SpyInstance; + let getIsLocalCIProfileStub: jest.SpyInstance; + let fetchQueueReleaseDateEstimationByParticipationIdStub: jest.SpyInstance; const exercise = { id: 20 } as Exercise; const student = { id: 99 }; @@ -41,6 +48,10 @@ describe('UpdatingResultComponent', () => { const newUngradedResult = { id: 15, rated: false } as Result; const submission = { id: 1 } as Submission; + const buildTimingInfo: BuildTimingInfo = { + buildStartDate: dayjs().subtract(10, 'second'), + estimatedCompletionDate: dayjs().add(10, 'second'), + }; beforeEach(() => { TestBed.configureTestingModule({ @@ -69,6 +80,10 @@ describe('UpdatingResultComponent', () => { getLatestPendingSubmissionStub = jest .spyOn(programmingSubmissionService, 'getLatestPendingSubmissionByParticipationId') .mockReturnValue(of(programmingSubmissionStateObj)); + getIsLocalCIProfileStub = jest.spyOn(programmingSubmissionService, 'getIsLocalCIProfile').mockReturnValue(false); + fetchQueueReleaseDateEstimationByParticipationIdStub = jest + .spyOn(programmingSubmissionService, 'fetchQueueReleaseDateEstimationByParticipationId') + .mockReturnValue(of(undefined)); }); }); @@ -153,12 +168,17 @@ describe('UpdatingResultComponent', () => { it('should set the isBuilding attribute to true if exerciseType is PROGRAMMING and there is a latest pending submission', () => { comp.exercise = { id: 99, type: ExerciseType.PROGRAMMING } as Exercise; - getLatestPendingSubmissionStub.mockReturnValue(of({ submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission, participationId: 3 })); + getLatestPendingSubmissionStub.mockReturnValue( + of({ submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission, participationId: 3, buildTimingInfo }), + ); cleanInitializeGraded(); expect(getLatestPendingSubmissionStub).toHaveBeenCalledOnce(); expect(getLatestPendingSubmissionStub).toHaveBeenCalledWith(comp.participation.id, comp.exercise.id, true); expect(comp.isBuilding).toBeTrue(); expect(comp.missingResultInfo).toBe(MissingResultInformation.NONE); + // LocalCI is not enabled, so the buildStartDate and estimatedCompletionDate should not be set + expect(comp.buildStartDate).toBeUndefined(); + expect(comp.estimatedCompletionDate).toBeUndefined(); }); it('should set the isBuilding attribute to false if exerciseType is PROGRAMMING and there is no pending submission anymore', () => { @@ -217,4 +237,32 @@ describe('UpdatingResultComponent', () => { expect(comp.isBuilding).toBeUndefined(); expect(comp.missingResultInfo).toBe(MissingResultInformation.NONE); }); + + it('should set the isQueue and isBuilding attribute to true with correct timing', () => { + getIsLocalCIProfileStub.mockReturnValue(true); + comp.exercise = { id: 99, type: ExerciseType.PROGRAMMING } as Exercise; + const pendingSubmissionSubject = new BehaviorSubject({ + submissionState: ProgrammingSubmissionState.IS_QUEUED, + submission, + participationId: 3, + } as ProgrammingSubmissionStateObj); + getLatestPendingSubmissionStub.mockReturnValue(pendingSubmissionSubject); + const queueReleaseDate = dayjs().add(3, 'second'); + fetchQueueReleaseDateEstimationByParticipationIdStub.mockReturnValue(of(queueReleaseDate)); + cleanInitializeGraded(); + expect(getLatestPendingSubmissionStub).toHaveBeenCalledOnce(); + expect(getLatestPendingSubmissionStub).toHaveBeenCalledWith(comp.participation.id, comp.exercise.id, true); + + expect(comp.isBuilding).toBeFalsy(); + expect(comp.isQueued).toBeTruthy(); + expect(comp.missingResultInfo).toBe(MissingResultInformation.NONE); + expect(comp.estimatedCompletionDate).toBe(queueReleaseDate); + + // Now the submission is building + pendingSubmissionSubject.next({ submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission, participationId: 3, buildTimingInfo }); + expect(comp.isBuilding).toBeTruthy(); + expect(comp.isQueued).toBeFalsy(); + expect(comp.buildStartDate).toBe(buildTimingInfo.buildStartDate); + expect(comp.estimatedCompletionDate).toBe(buildTimingInfo.estimatedCompletionDate); + }); }); diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-programming-submission.service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-programming-submission.service.ts index 582c51dd4ba1..c6d43b74971d 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-programming-submission.service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-programming-submission.service.ts @@ -1,5 +1,6 @@ import { IProgrammingSubmissionService, ProgrammingSubmissionState, ProgrammingSubmissionStateObj } from 'app/exercises/programming/participate/programming-submission.service'; import { EMPTY, Observable, of } from 'rxjs'; +import dayjs from 'dayjs'; import { Exercise } from 'app/entities/exercise.model'; export class MockProgrammingSubmissionService implements IProgrammingSubmissionService { @@ -14,4 +15,6 @@ export class MockProgrammingSubmissionService implements IProgrammingSubmissionS triggerInstructorBuildForAllParticipationsOfExercise: (exerciseId: number) => Observable; triggerInstructorBuildForParticipationsOfExercise: (exerciseId: number, participationIds: number[]) => Observable; downloadSubmissionInOrion: (exerciseId: number, submissionId: number, correctionRound: number) => void; + getIsLocalCIProfile = () => false; + fetchQueueReleaseDateEstimationByParticipationId: (participationId: number) => Observable = () => of(undefined); } diff --git a/src/test/javascript/spec/service/programming-submission.service.spec.ts b/src/test/javascript/spec/service/programming-submission.service.spec.ts index eaed7a0be320..2cec23b11bbd 100644 --- a/src/test/javascript/spec/service/programming-submission.service.spec.ts +++ b/src/test/javascript/spec/service/programming-submission.service.spec.ts @@ -3,6 +3,7 @@ import { BehaviorSubject, Subject, lastValueFrom, of } from 'rxjs'; import { range as _range } from 'lodash-es'; import { MockWebsocketService } from '../helpers/mocks/service/mock-websocket.service'; import { + BuildTimingInfo, ExerciseSubmissionState, ProgrammingSubmissionService, ProgrammingSubmissionState, @@ -17,9 +18,12 @@ import { MockParticipationWebsocketService } from '../helpers/mocks/service/mock import { ProgrammingExerciseParticipationService } from 'app/exercises/programming/manage/services/programming-exercise-participation.service'; import { MockProgrammingExerciseParticipationService } from '../helpers/mocks/service/mock-programming-exercise-participation.service'; import { HttpClient, provideHttpClient } from '@angular/common/http'; -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { TestBed, discardPeriodicTasks, fakeAsync, tick } from '@angular/core/testing'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { ProfileService } from '../../../../main/webapp/app/shared/layouts/profiles/profile.service'; +import { MockProfileService } from '../helpers/mocks/service/mock-profile.service'; +import { SubmissionProcessingDTO } from '../../../../main/webapp/app/entities/programming/submission-processing-dto'; describe('ProgrammingSubmissionService', () => { let websocketService: JhiWebsocketService; @@ -38,20 +42,46 @@ describe('ProgrammingSubmissionService', () => { let notifyAllResultSubscribersStub: jest.SpyInstance; let wsSubmissionSubject: Subject; + let wsSubmissionProcessingSubject: Subject; let wsLatestResultSubject: Subject; const participationId = 1; + const exerciseId = 10; const submissionTopic = `/user/topic/newSubmissions`; + const submissionProcessingTopic = `/user/topic/submissionProcessing`; let currentSubmission: Submission; let currentSubmission2: Submission; + let currentProgrammingSubmission: ProgrammingSubmission; + let currentProgrammingSubmissionOld: ProgrammingSubmission; let result: Result; let result2: Result; + let buildTimingInfoEmpty: BuildTimingInfo; + let buildTimingInfo: BuildTimingInfo; + let mockSubmissionProcessingDTO: SubmissionProcessingDTO; + let mockSubmissionProcessingDTOOld: SubmissionProcessingDTO; beforeEach(() => { currentSubmission = { id: 11, submissionDate: dayjs().subtract(20, 'seconds'), participation: { id: participationId } } as any; currentSubmission2 = { id: 12, submissionDate: dayjs().subtract(20, 'seconds'), participation: { id: participationId } } as any; result = { id: 31, submission: currentSubmission } as any; result2 = { id: 32, submission: currentSubmission2 } as any; + buildTimingInfoEmpty = { buildStartDate: undefined, estimatedCompletionDate: undefined }; + buildTimingInfo = { buildStartDate: dayjs().subtract(10, 'seconds'), estimatedCompletionDate: dayjs().add(10, 'seconds') }; + currentProgrammingSubmission = { id: 12, submissionDate: dayjs().subtract(20, 'seconds'), participation: { id: participationId }, commitHash: 'abc123' } as any; + currentProgrammingSubmissionOld = { id: 11, submissionDate: dayjs().subtract(40, 'seconds'), participation: { id: participationId }, commitHash: 'abc123Old' } as any; + mockSubmissionProcessingDTO = { + exerciseId: exerciseId, + participationId: participationId, + commitHash: 'abc123', + estimatedCompletionDate: buildTimingInfo.estimatedCompletionDate, + buildStartDate: buildTimingInfo.buildStartDate, + submissionDate: dayjs().subtract(20, 'seconds'), + }; + mockSubmissionProcessingDTOOld = { + ...mockSubmissionProcessingDTO, + commitHash: 'abc123Old', + submissionDate: dayjs().subtract(40, 'seconds'), + }; TestBed.configureTestingModule({ imports: [], @@ -61,6 +91,7 @@ describe('ProgrammingSubmissionService', () => { { provide: JhiWebsocketService, useClass: MockWebsocketService }, { provide: ParticipationWebsocketService, useClass: MockParticipationWebsocketService }, { provide: ProgrammingExerciseParticipationService, useClass: MockProgrammingExerciseParticipationService }, + { provide: ProfileService, useClass: MockProfileService }, ], }) .compileComponents() @@ -76,7 +107,15 @@ describe('ProgrammingSubmissionService', () => { wsSubscribeStub = jest.spyOn(websocketService, 'subscribe'); wsUnsubscribeStub = jest.spyOn(websocketService, 'unsubscribe'); wsSubmissionSubject = new Subject(); - wsReceiveStub = jest.spyOn(websocketService, 'receive').mockReturnValue(wsSubmissionSubject); + wsSubmissionProcessingSubject = new Subject(); + wsReceiveStub = jest.spyOn(websocketService, 'receive').mockImplementation((topic: string) => { + if (topic === submissionTopic) { + return wsSubmissionSubject; + } else if (topic === submissionProcessingTopic) { + return wsSubmissionProcessingSubject; + } + return new Subject(); + }); wsLatestResultSubject = new Subject(); participationWsLatestResultStub = jest .spyOn(participationWebsocketService, 'subscribeForLatestResultOfParticipation') @@ -110,8 +149,14 @@ describe('ProgrammingSubmissionService', () => { it('should query httpService endpoint and setup the websocket subscriptions if no subject is cached for the provided participation', () => { httpGetStub.mockReturnValue(of(currentSubmission)); let submission; + submissionService.setLocalCIProfile(false); submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true).subscribe((sub) => (submission = sub)); - expect(submission).toEqual({ submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId }); + expect(submission).toEqual({ + submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, + submission: currentSubmission, + participationId, + buildTimingInfo: buildTimingInfoEmpty, + }); expect(wsSubscribeStub).toHaveBeenCalledOnce(); expect(wsSubscribeStub).toHaveBeenCalledWith(submissionTopic); expect(wsReceiveStub).toHaveBeenCalledOnce(); @@ -120,16 +165,39 @@ describe('ProgrammingSubmissionService', () => { expect(participationWsLatestResultStub).toHaveBeenCalledWith(participationId, true, 10); }); + it('should query httpService endpoint and setup the websocket subscriptions if no subject is cached for the provided participation with localCI profile', () => { + httpGetStub.mockReturnValue(of(currentSubmission)); + let submission; + submissionService.setLocalCIProfile(true); + submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true).subscribe((sub) => (submission = sub)); + expect(submission).toEqual({ + submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, + submission: currentSubmission, + participationId, + buildTimingInfo: buildTimingInfoEmpty, + }); + expect(wsSubscribeStub).toHaveBeenCalledTimes(2); + expect(wsSubscribeStub).toHaveBeenNthCalledWith(1, submissionTopic); + expect(wsSubscribeStub).toHaveBeenNthCalledWith(2, submissionProcessingTopic); + expect(wsReceiveStub).toHaveBeenCalledTimes(2); + expect(wsReceiveStub).toHaveBeenNthCalledWith(1, submissionTopic); + expect(wsReceiveStub).toHaveBeenNthCalledWith(2, submissionProcessingTopic); + expect(participationWsLatestResultStub).toHaveBeenCalledOnce(); + expect(participationWsLatestResultStub).toHaveBeenCalledWith(participationId, true, 10); + }); + it('should emit undefined when a new result comes in for the given participation to signal that the building process is over', () => { const returnedSubmissions: Array = []; httpGetStub.mockReturnValue(of(currentSubmission)); submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true).subscribe((s) => returnedSubmissions.push(s)); - expect(returnedSubmissions).toEqual([{ submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId }]); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId, buildTimingInfo: buildTimingInfoEmpty }, + ]); // Result comes in for submission. result.submission = currentSubmission; wsLatestResultSubject.next(result); expect(returnedSubmissions).toEqual([ - { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId }, + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId, buildTimingInfo: buildTimingInfoEmpty }, { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, ]); }); @@ -138,11 +206,15 @@ describe('ProgrammingSubmissionService', () => { const returnedSubmissions: Array = []; httpGetStub.mockReturnValue(of(currentSubmission)); submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true).subscribe((s) => returnedSubmissions.push(s)); - expect(returnedSubmissions).toEqual([{ submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId }]); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId, buildTimingInfo: buildTimingInfoEmpty }, + ]); // Result comes in for submission. result.submission = currentSubmission2; wsLatestResultSubject.next(result); - expect(returnedSubmissions).toEqual([{ submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId }]); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentSubmission, participationId, buildTimingInfo: buildTimingInfoEmpty }, + ]); }); it('should emit the newest submission when it was received through the websocket connection', () => { @@ -371,4 +443,148 @@ describe('ProgrammingSubmissionService', () => { submissionService.unsubscribeForLatestSubmissionOfParticipation(2); expect(wsUnsubscribeStub).toHaveBeenCalledOnce(); }); + + it('should only unsubscribe if no other participations use the topic with localci', () => { + submissionService.setLocalCIProfile(true); + httpGetStub.mockReturnValue(of(currentSubmission)); + submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true); + submissionService.getLatestPendingSubmissionByParticipationId(2, 10, true); + + // Should not unsubscribe as participation 2 still uses the same topic + submissionService.unsubscribeForLatestSubmissionOfParticipation(participationId); + expect(wsUnsubscribeStub).not.toHaveBeenCalled(); + + // Should now unsubscribe as last participation for topic was unsubscribed + submissionService.unsubscribeForLatestSubmissionOfParticipation(2); + expect(wsUnsubscribeStub).toHaveBeenCalledTimes(2); + }); + + it('should emit the newest submission when it was received through the websocket connection with localci', () => { + submissionService.setLocalCIProfile(true); + const returnedSubmissions: Array = []; + // No latest pending submission found. + httpGetStub.mockReturnValue(of(undefined)); + submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true).subscribe((s) => returnedSubmissions.push(s)); + expect(returnedSubmissions).toEqual([{ submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }]); + // New submission comes in. + wsSubmissionSubject.next(currentProgrammingSubmission); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + ]); + // Submission is now building. + wsSubmissionProcessingSubject.next(mockSubmissionProcessingDTO); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentProgrammingSubmission, participationId, buildTimingInfo }, + ]); + // Result comes in for submission. + result.submission = currentProgrammingSubmission; + wsLatestResultSubject.next(result); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentProgrammingSubmission, participationId, buildTimingInfo }, + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + ]); + }); + + it('should handle when submission processing event before submission event', () => { + submissionService.setLocalCIProfile(true); + const returnedSubmissions: Array = []; + // No latest pending submission found. + httpGetStub.mockReturnValue(of(undefined)); + submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true).subscribe((s) => returnedSubmissions.push(s)); + expect(returnedSubmissions).toEqual([{ submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }]); + // Submission is now building. + wsSubmissionProcessingSubject.next(mockSubmissionProcessingDTO); + expect(returnedSubmissions).toEqual([{ submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }]); + // New submission comes in. + wsSubmissionSubject.next(currentProgrammingSubmission); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentProgrammingSubmission, participationId, buildTimingInfo }, + ]); + // Result comes in for submission. + result.submission = currentProgrammingSubmission; + wsLatestResultSubject.next(result); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentProgrammingSubmission, participationId, buildTimingInfo }, + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + ]); + }); + + it('should not update to building if old submission', () => { + submissionService.setLocalCIProfile(true); + const returnedSubmissions: Array = []; + // No latest pending submission found. + httpGetStub.mockReturnValue(of(undefined)); + submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true).subscribe((s) => returnedSubmissions.push(s)); + expect(returnedSubmissions).toEqual([{ submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }]); + // New submission comes in. + wsSubmissionSubject.next(currentProgrammingSubmissionOld); + wsSubmissionSubject.next(currentProgrammingSubmission); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmissionOld, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + ]); + // old submission is now building. + wsSubmissionProcessingSubject.next(mockSubmissionProcessingDTOOld); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmissionOld, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + ]); + // new submission is now building. + wsSubmissionProcessingSubject.next(mockSubmissionProcessingDTO); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmissionOld, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + { + submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, + submission: currentProgrammingSubmission, + participationId, + buildTimingInfo: buildTimingInfo, + }, + ]); + }); + + it('should change to building when queue timer ends', fakeAsync(() => { + // @ts-ignore + submissionService.currentExpectedQueueEstimate = 1000; + submissionService.setLocalCIProfile(true); + const returnedSubmissions: Array = []; + // No latest pending submission found. + httpGetStub.mockReturnValue(of(undefined)); + submissionService.getLatestPendingSubmissionByParticipationId(participationId, 10, true).subscribe((s) => returnedSubmissions.push(s)); + expect(returnedSubmissions).toEqual([{ submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }]); + // New submission comes in. + currentProgrammingSubmission.submissionDate = dayjs(); + wsSubmissionSubject.next(currentProgrammingSubmission); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + ]); + + tick(1000); + + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentProgrammingSubmission, participationId }, + ]); + + wsSubmissionProcessingSubject.next(mockSubmissionProcessingDTO); + expect(returnedSubmissions).toEqual([ + { submissionState: ProgrammingSubmissionState.HAS_NO_PENDING_SUBMISSION, submission: undefined, participationId }, + { submissionState: ProgrammingSubmissionState.IS_QUEUED, submission: currentProgrammingSubmission, participationId }, + { submissionState: ProgrammingSubmissionState.IS_BUILDING_PENDING_SUBMISSION, submission: currentProgrammingSubmission, participationId }, + ]); + + discardPeriodicTasks(); + })); }); From d96a43798a886b39157c0eb3d6bbf9e1d8f4003b Mon Sep 17 00:00:00 2001 From: Benjamin Schmitz <66966223+bensofficial@users.noreply.github.com> Date: Tue, 17 Dec 2024 20:23:51 +0100 Subject: [PATCH 08/12] Development: Add test server 7 to GitHub deployment (#10042) --- .github/workflows/testserver.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/testserver.yml b/.github/workflows/testserver.yml index db5b0fce1000..f99ae615b36b 100644 --- a/.github/workflows/testserver.yml +++ b/.github/workflows/testserver.yml @@ -138,12 +138,13 @@ jobs: folder: /opt/artemis host_keys: | - #- environment: artemis-test7.artemis.cit.tum.de - # label-identifier: artemis-test7 - # url: https://artemis-test7.artemis.cit.tum.de - # user: deployment - # hosts: artemis-test7.artemis.cit.tum.de - # folder: /opt/artemis + - environment: artemis-test7.artemis.cit.tum.de + label-identifier: artemis-test7 + url: https://artemis-test7.artemis.cit.tum.de + user: deployment + hosts: artemis-test7.artemis.cit.tum.de + folder: /opt/artemis + host_keys: | #- environment: artemis-test8.artemis.cit.tum.de # label-identifier: artemis-test8 From 3a6832e88ba1a3c5f44251d2f893d3bea89b1d0a Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Tue, 17 Dec 2024 20:52:06 +0100 Subject: [PATCH 09/12] Development: Update server dependencies --- build.gradle | 15 +++++++++++---- gradle.properties | 10 ++++++---- gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/build.gradle b/build.gradle index 00f7bbb49d39..4091d7584bc7 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,7 @@ plugins { id "idea" id "jacoco" id "org.springframework.boot" version "${spring_boot_version}" - id "io.spring.dependency-management" version "1.1.6" + id "io.spring.dependency-management" version "1.1.7" id "com.google.cloud.tools.jib" version "3.4.4" id "com.github.node-gradle.node" version "${gradle_node_plugin_version}" id "com.diffplug.spotless" version "6.25.0" @@ -384,6 +384,13 @@ dependencies { exclude module: "spring-boot-starter-undertow" } implementation "org.springframework.boot:spring-boot-starter-tomcat:${spring_boot_version}" + + // Avoid security issues in Tomcat 10.1.33 + implementation "org.apache.tomcat.embed:tomcat-embed-core:${tomcat_version}" + implementation "org.apache.tomcat.embed:tomcat-embed-el:${tomcat_version}" + implementation "org.apache.tomcat.embed:tomcat-embed-websocket:${tomcat_version}" + implementation "org.apache.tomcat:tomcat-annotations-api:${tomcat_version}" + implementation "org.springframework.boot:spring-boot-starter-websocket:${spring_boot_version}" implementation "org.springframework.boot:spring-boot-starter-thymeleaf:${spring_boot_version}" implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server:${spring_boot_version}" @@ -400,7 +407,7 @@ dependencies { implementation "org.springframework.cloud:spring-cloud-starter-config:${spring_cloud_version}" implementation "org.springframework.cloud:spring-cloud-commons:${spring_cloud_version}" - implementation "io.netty:netty-all:4.1.115.Final" + implementation "io.netty:netty-all:4.1.116.Final" implementation "io.projectreactor.netty:reactor-netty:1.2.1" implementation "org.springframework:spring-messaging:${spring_framework_version}" implementation "org.springframework.retry:spring-retry:2.0.11" @@ -451,7 +458,7 @@ dependencies { implementation "org.apfloat:apfloat:1.14.0" // use newest version of guava to avoid security issues through outdated dependencies - implementation "com.google.guava:guava:33.3.1-jre" + implementation "com.google.guava:guava:33.4.0-jre" implementation "com.sun.activation:jakarta.activation:2.0.1" // use newest version of gson to avoid security issues through outdated dependencies @@ -607,7 +614,7 @@ tasks.withType(Test).configureEach { } wrapper { - gradleVersion = "8.12-rc-1" + gradleVersion = "8.12-rc-2" } tasks.register("stage") { diff --git a/gradle.properties b/gradle.properties index de944d16001d..c110eb971ddc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,7 @@ jhipster_dependencies_version=8.7.2 spring_boot_version=3.4.0 spring_framework_version=6.2.1 spring_cloud_version=4.2.0 -spring_security_version=6.4.1 +spring_security_version=6.4.2 # TODO: upgrading to 6.6.x currently leads to issues due to internal changes in Hibernate and potentially wrong use in Artemis server code hibernate_version=6.4.10.Final # TODO: can we update to 5.x? @@ -19,7 +19,8 @@ jwt_version=0.12.6 jaxb_runtime_version=4.0.5 hazelcast_version=5.5.0 fasterxml_version=2.18.2 -jgit_version=7.1.0.202411261347-r +# TODO: 7.1.0 includes bugs related to git diffs, therefore we cannot update +jgit_version=7.0.0.202409031743-r sshd_version=2.14.0 checkstyle_version=10.21.0 jplag_version=5.1.0 @@ -32,13 +33,14 @@ liquibase_version=4.30.0 docker_java_version=3.4.1 logback_version=1.5.12 java_parser_version=3.26.2 -byte_buddy_version=1.15.10 +byte_buddy_version=1.15.11 netty_version=4.1.115.Final +tomcat_version=10.1.34 # testing # make sure both versions are compatible junit_version=5.11.3 -junit_platform_version=1.11.3 +junit_platform_version=1.11.4 mockito_version=5.14.2 testcontainer_version=1.20.4 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9e40988550fd..fb4b1a2e2ced 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-rc-1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-rc-2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 92b2aad9cf275f9eb85f4d4e971317a15b8950f6 Mon Sep 17 00:00:00 2001 From: Florian Glombik <63976129+florian-glombik@users.noreply.github.com> Date: Wed, 18 Dec 2024 15:06:54 +0100 Subject: [PATCH 10/12] Lectures: Add dismiss modal for unsaved changes to title or period section (#10023) --- .../exercise-title-channel-name.component.ts | 6 +- .../close-edit-lecture-modal.component.html | 34 ++++ .../close-edit-lecture-modal.component.ts | 25 +++ .../lecture/hasLectureUnsavedChanges.guard.ts | 28 ++++ .../lecture-period.component.html} | 8 +- .../lecture-period.component.ts | 23 +++ .../lecture-title-channel-name.component.ts | 5 +- .../app/lecture/lecture-update.component.html | 56 +++---- .../app/lecture/lecture-update.component.ts | 147 +++++++++++++----- src/main/webapp/app/lecture/lecture.module.ts | 4 +- src/main/webapp/app/lecture/lecture.route.ts | 2 + .../lecture-update-wizard.component.html | 2 +- .../lecture-wizard-period.component.ts | 12 -- .../title-channel-name.component.ts | 6 +- src/main/webapp/i18n/de/lecture.json | 10 +- src/main/webapp/i18n/en/lecture.json | 10 +- ...close-edit-lecture-modal.component.spec.ts | 22 +++ .../hasLectureUnsavedChanges.guard.spec.ts | 95 +++++++++++ ...ec.ts => lecture-period.component.spec.ts} | 19 +-- .../lecture/lecture-update.component.spec.ts | 133 ++++++++++++---- .../lecture-wizard-title.component.spec.ts | 20 ++- .../lecture-wizard.component.spec.ts | 26 ++-- 22 files changed, 533 insertions(+), 160 deletions(-) create mode 100644 src/main/webapp/app/lecture/close-edit-lecture-dialog/close-edit-lecture-modal.component.html create mode 100644 src/main/webapp/app/lecture/close-edit-lecture-dialog/close-edit-lecture-modal.component.ts create mode 100644 src/main/webapp/app/lecture/hasLectureUnsavedChanges.guard.ts rename src/main/webapp/app/lecture/{wizard-mode/lecture-wizard-period.component.html => lecture-period/lecture-period.component.html} (78%) create mode 100644 src/main/webapp/app/lecture/lecture-period/lecture-period.component.ts delete mode 100644 src/main/webapp/app/lecture/wizard-mode/lecture-wizard-period.component.ts create mode 100644 src/test/javascript/spec/component/lecture/close-edit-lecture-modal.component.spec.ts create mode 100644 src/test/javascript/spec/component/lecture/hasLectureUnsavedChanges.guard.spec.ts rename src/test/javascript/spec/component/lecture/{wizard-mode/lecture-wizard-period.component.spec.ts => lecture-period.component.spec.ts} (54%) diff --git a/src/main/webapp/app/exercises/shared/exercise-title-channel-name/exercise-title-channel-name.component.ts b/src/main/webapp/app/exercises/shared/exercise-title-channel-name/exercise-title-channel-name.component.ts index 5ad7384d3d17..5b53397e616e 100644 --- a/src/main/webapp/app/exercises/shared/exercise-title-channel-name/exercise-title-channel-name.component.ts +++ b/src/main/webapp/app/exercises/shared/exercise-title-channel-name/exercise-title-channel-name.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, ViewChild, effect, inject, input, signal } from '@angular/core'; +import { Component, Input, OnChanges, SimpleChanges, ViewChild, effect, inject, input, output, signal } from '@angular/core'; import { Course, isCommunicationEnabled } from 'app/entities/course.model'; import { Exercise } from 'app/entities/exercise.model'; import { TitleChannelNameComponent } from 'app/shared/form/title-channel-name/title-channel-name.component'; @@ -22,8 +22,8 @@ export class ExerciseTitleChannelNameComponent implements OnChanges { @ViewChild(TitleChannelNameComponent) titleChannelNameComponent: TitleChannelNameComponent; - @Output() onTitleChange = new EventEmitter(); - @Output() onChannelNameChange = new EventEmitter(); + onTitleChange = output(); + onChannelNameChange = output(); private readonly exerciseService: ExerciseService = inject(ExerciseService); diff --git a/src/main/webapp/app/lecture/close-edit-lecture-dialog/close-edit-lecture-modal.component.html b/src/main/webapp/app/lecture/close-edit-lecture-dialog/close-edit-lecture-modal.component.html new file mode 100644 index 000000000000..afc317c054bc --- /dev/null +++ b/src/main/webapp/app/lecture/close-edit-lecture-dialog/close-edit-lecture-modal.component.html @@ -0,0 +1,34 @@ + + + diff --git a/src/main/webapp/app/lecture/close-edit-lecture-dialog/close-edit-lecture-modal.component.ts b/src/main/webapp/app/lecture/close-edit-lecture-dialog/close-edit-lecture-modal.component.ts new file mode 100644 index 000000000000..cd7024b6c5af --- /dev/null +++ b/src/main/webapp/app/lecture/close-edit-lecture-dialog/close-edit-lecture-modal.component.ts @@ -0,0 +1,25 @@ +import { Component, Input, inject } from '@angular/core'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'jhi-close-edit-lecture-modal', + standalone: true, + imports: [TranslateDirective, ArtemisSharedCommonModule], + templateUrl: './close-edit-lecture-modal.component.html', +}) +export class CloseEditLectureModalComponent { + protected readonly faTimes = faTimes; + + protected readonly activeModal = inject(NgbActiveModal); + + // no input signals yet as they can not be initialized with current ng-bootstrap version https://stackoverflow.com/a/79094268/16540383 + @Input() hasUnsavedChangesInTitleSection: boolean; + @Input() hasUnsavedChangesInPeriodSection: boolean; + + closeWindow(isCloseConfirmed: boolean): void { + this.activeModal.close(isCloseConfirmed); + } +} diff --git a/src/main/webapp/app/lecture/hasLectureUnsavedChanges.guard.ts b/src/main/webapp/app/lecture/hasLectureUnsavedChanges.guard.ts new file mode 100644 index 000000000000..b292d2413830 --- /dev/null +++ b/src/main/webapp/app/lecture/hasLectureUnsavedChanges.guard.ts @@ -0,0 +1,28 @@ +import { inject } from '@angular/core'; +import { CanDeactivateFn } from '@angular/router'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { LectureUpdateComponent } from 'app/lecture/lecture-update.component'; +import { Observable, from, of } from 'rxjs'; +import { CloseEditLectureModalComponent } from 'app/lecture/close-edit-lecture-dialog/close-edit-lecture-modal.component'; + +export const hasLectureUnsavedChangesGuard: CanDeactivateFn = (component: LectureUpdateComponent): Observable => { + if (!component.shouldDisplayDismissWarning || component.isShowingWizardMode) { + return of(true); + } + + if (component.isChangeMadeToTitleOrPeriodSection) { + const modalService = inject(NgbModal); + + const modalRef: NgbModalRef = modalService.open(CloseEditLectureModalComponent, { + size: 'lg', + backdrop: 'static', + animation: true, + }); + modalRef.componentInstance.hasUnsavedChangesInTitleSection = component.isChangeMadeToTitleSection(); + modalRef.componentInstance.hasUnsavedChangesInPeriodSection = component.isChangeMadeToPeriodSection(); + + return from(modalRef.result); + } + + return of(true); +}; diff --git a/src/main/webapp/app/lecture/wizard-mode/lecture-wizard-period.component.html b/src/main/webapp/app/lecture/lecture-period/lecture-period.component.html similarity index 78% rename from src/main/webapp/app/lecture/wizard-mode/lecture-wizard-period.component.html rename to src/main/webapp/app/lecture/lecture-period/lecture-period.component.html index 7e205ffa58f9..5434a21491a2 100644 --- a/src/main/webapp/app/lecture/wizard-mode/lecture-wizard-period.component.html +++ b/src/main/webapp/app/lecture/lecture-period/lecture-period.component.html @@ -1,11 +1,11 @@
-

+

(); + @Input() validateDatesFunction: () => void; + + periodSectionDatepickers = viewChildren(FormDateTimePickerComponent); + + isPeriodSectionValid: Signal = computed(() => { + for (const periodSectionDatepicker of this.periodSectionDatepickers()) { + if (!periodSectionDatepicker.isValid()) { + return false; + } + } + return true; + }); +} diff --git a/src/main/webapp/app/lecture/lecture-title-channel-name.component.ts b/src/main/webapp/app/lecture/lecture-title-channel-name.component.ts index 0bda4027f089..f2c8ffbc915e 100644 --- a/src/main/webapp/app/lecture/lecture-title-channel-name.component.ts +++ b/src/main/webapp/app/lecture/lecture-title-channel-name.component.ts @@ -1,6 +1,7 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnInit, viewChild } from '@angular/core'; import { isCommunicationEnabled } from 'app/entities/course.model'; import { Lecture } from 'app/entities/lecture.model'; +import { TitleChannelNameComponent } from 'app/shared/form/title-channel-name/title-channel-name.component'; @Component({ selector: 'jhi-lecture-title-channel-name', @@ -9,6 +10,8 @@ import { Lecture } from 'app/entities/lecture.model'; export class LectureTitleChannelNameComponent implements OnInit { @Input() lecture: Lecture; + titleChannelNameComponent = viewChild.required(TitleChannelNameComponent); + hideChannelNameInput = false; ngOnInit() { this.hideChannelNameInput = !this.requiresChannelName(this.lecture); diff --git a/src/main/webapp/app/lecture/lecture-update.component.html b/src/main/webapp/app/lecture/lecture-update.component.html index 9ba49083c37c..332e6b86f4a1 100644 --- a/src/main/webapp/app/lecture/lecture-update.component.html +++ b/src/main/webapp/app/lecture/lecture-update.component.html @@ -5,7 +5,7 @@ [toggleModeFunction]="toggleModeFunction" [saveLectureFunction]="saveLectureFunction" [validateDatesFunction]="onDatesValuesChanged" - [lecture]="lecture" + [lecture]="lecture()" [isSaving]="isSaving" /> } @@ -27,45 +27,24 @@

+

+

+
- -
-
-
- -
-
- -
-
- -
+
- @if (lecture.course) { + + @if (lecture().course) {
- +
} @@ -116,7 +95,14 @@

  -

diff --git a/src/main/webapp/app/lecture/lecture-update.component.ts b/src/main/webapp/app/lecture/lecture-update.component.ts index b72ea717afd9..c4ec628fc417 100644 --- a/src/main/webapp/app/lecture/lecture-update.component.ts +++ b/src/main/webapp/app/lecture/lecture-update.component.ts @@ -1,10 +1,9 @@ -import { Component, OnInit, ViewChild } from '@angular/core'; +import { Component, OnDestroy, OnInit, ViewChild, effect, inject, signal, viewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, Subscription } from 'rxjs'; import { AlertService } from 'app/core/util/alert.service'; import { LectureService } from './lecture.service'; -import { CourseManagementService } from '../course/manage/course-management.service'; import { Lecture } from 'app/entities/lecture.model'; import { Course } from 'app/entities/course.model'; import { onError } from 'app/shared/util/global.utils'; @@ -12,29 +11,43 @@ import { ArtemisNavigationUtilService } from 'app/utils/navigation.utils'; import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; import { faBan, faHandshakeAngle, faPuzzlePiece, faQuestionCircle, faSave } from '@fortawesome/free-solid-svg-icons'; import { LectureUpdateWizardComponent } from 'app/lecture/wizard-mode/lecture-update-wizard.component'; -import { UPLOAD_FILE_EXTENSIONS } from 'app/shared/constants/file-extensions.constants'; +import { ACCEPTED_FILE_EXTENSIONS_FILE_BROWSER, ALLOWED_FILE_EXTENSIONS_HUMAN_READABLE } from 'app/shared/constants/file-extensions.constants'; 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 { FormDateTimePickerComponent } from 'app/shared/date-time-picker/date-time-picker.component'; +import cloneDeep from 'lodash-es/cloneDeep'; @Component({ selector: 'jhi-lecture-update', templateUrl: './lecture-update.component.html', styleUrls: ['./lecture-update.component.scss'], }) -export class LectureUpdateComponent implements OnInit { +export class LectureUpdateComponent implements OnInit, OnDestroy { protected readonly documentationType: DocumentationType = 'Lecture'; protected readonly faQuestionCircle = faQuestionCircle; protected readonly faSave = faSave; protected readonly faPuzzleProcess = faPuzzlePiece; protected readonly faBan = faBan; protected readonly faHandShakeAngle = faHandshakeAngle; - // A human-readable list of allowed file extensions - protected readonly allowedFileExtensions = UPLOAD_FILE_EXTENSIONS.join(', '); - // The list of file extensions for the "accept" attribute of the file input field - protected readonly acceptedFileExtensionsFileBrowser = UPLOAD_FILE_EXTENSIONS.map((ext) => '.' + ext).join(','); + + protected readonly allowedFileExtensions = ALLOWED_FILE_EXTENSIONS_HUMAN_READABLE; + protected readonly acceptedFileExtensionsFileBrowser = ACCEPTED_FILE_EXTENSIONS_FILE_BROWSER; @ViewChild(LectureUpdateWizardComponent, { static: false }) wizardComponent: LectureUpdateWizardComponent; - lecture: Lecture; + private readonly alertService = inject(AlertService); + private readonly lectureService = inject(LectureService); + private readonly activatedRoute = inject(ActivatedRoute); + private readonly navigationUtilService = inject(ArtemisNavigationUtilService); + private readonly router = inject(Router); + + titleSection = viewChild(LectureTitleChannelNameComponent); + lecturePeriodSection = viewChild(LectureUpdatePeriodComponent); + + lecture = signal(new Lecture()); + lectureOnInit: Lecture; isSaving: boolean; isProcessing: boolean; processUnitMode: boolean; @@ -50,18 +63,41 @@ export class LectureUpdateComponent implements OnInit { toggleModeFunction = () => this.toggleWizardMode(); saveLectureFunction = () => this.save(); - constructor( - protected alertService: AlertService, - protected lectureService: LectureService, - protected courseService: CourseManagementService, - protected activatedRoute: ActivatedRoute, - private navigationUtilService: ArtemisNavigationUtilService, - private router: Router, - ) {} + isChangeMadeToTitleOrPeriodSection = false; + shouldDisplayDismissWarning = true; + + private subscriptions = new Subscription(); + + constructor() { + effect(() => { + if (this.titleSection()?.titleChannelNameComponent() && this.lecturePeriodSection()) { + this.subscriptions.add( + this.titleSection()! + .titleChannelNameComponent() + .titleChange.subscribe(() => { + this.updateIsChangesMadeToTitleOrPeriodSection(); + }), + ); + this.subscriptions.add( + this.titleSection()! + .titleChannelNameComponent() + .channelNameChange.subscribe(() => { + this.updateIsChangesMadeToTitleOrPeriodSection(); + }), + ); + this.subscriptions.add( + this.lecturePeriodSection()! + .periodSectionDatepickers() + .forEach((datepicker: FormDateTimePickerComponent) => { + datepicker.valueChange.subscribe(() => { + this.updateIsChangesMadeToTitleOrPeriodSection(); + }); + }), + ); + } + }); + } - /** - * Life cycle hook called by Angular to indicate that Angular is done creating the component - */ ngOnInit() { this.isSaving = false; this.processUnitMode = false; @@ -70,10 +106,10 @@ export class LectureUpdateComponent implements OnInit { this.activatedRoute.parent!.data.subscribe((data) => { // Create a new lecture to use unless we fetch an existing lecture const lecture = data['lecture']; - this.lecture = lecture ?? new Lecture(); + this.lecture.set(lecture ?? new Lecture()); const course = data['course']; if (course) { - this.lecture.course = course; + this.lecture().course = course; } }); @@ -82,6 +118,42 @@ export class LectureUpdateComponent implements OnInit { this.isShowingWizardMode = params.shouldBeInWizardMode; } }); + + this.lectureOnInit = cloneDeep(this.lecture()); + } + + ngOnDestroy() { + this.subscriptions.unsubscribe(); + } + + isChangeMadeToTitleSection() { + return ( + this.lecture().title !== this.lectureOnInit.title || + this.lecture().channelName !== this.lectureOnInit.channelName || + (this.lecture().description ?? '') !== (this.lectureOnInit.description ?? '') + ); + } + + isChangeMadeToPeriodSection() { + const { visibleDate, startDate, endDate } = this.lecture(); + const { visibleDate: visibleDateOnInit, startDate: startDateOnInit, endDate: endDateOnInit } = this.lectureOnInit; + + const isInvalid = (date: Dayjs | undefined) => !dayjs(date).isValid(); + const isSame = (date1: Dayjs | undefined, date2: Dayjs | undefined) => dayjs(date1).isSame(dayjs(date2)); + + const emptyVisibleDateWasCleared = !visibleDateOnInit && isInvalid(visibleDate); + const emptyStartDateWasCleared = !startDateOnInit && isInvalid(startDate); + const emptyEndDateWasCleared = !endDateOnInit && isInvalid(endDate); + + return ( + (!isSame(visibleDate, visibleDateOnInit) && !emptyVisibleDateWasCleared) || + (!isSame(startDate, startDateOnInit) && !emptyStartDateWasCleared) || + (!isSame(endDate, endDateOnInit) && !emptyEndDateWasCleared) + ); + } + + protected updateIsChangesMadeToTitleOrPeriodSection() { + this.isChangeMadeToTitleOrPeriodSection = this.isChangeMadeToTitleSection() || this.isChangeMadeToPeriodSection(); } /** @@ -90,7 +162,8 @@ export class LectureUpdateComponent implements OnInit { * Returns to the overview page if there is no previous state, and we created a new lecture */ previousState() { - this.navigationUtilService.navigateBackWithOptional(['course-management', this.lecture.course!.id!.toString(), 'lectures'], this.lecture.id?.toString()); + this.shouldDisplayDismissWarning = false; + this.navigationUtilService.navigateBackWithOptional(['course-management', this.lecture().course!.id!.toString(), 'lectures'], this.lecture().id?.toString()); } /** @@ -98,13 +171,14 @@ export class LectureUpdateComponent implements OnInit { * This function is called by pressing save after creating or editing a lecture */ save() { + this.shouldDisplayDismissWarning = false; this.isSaving = true; this.isProcessing = true; - if (this.lecture.id !== undefined) { - this.subscribeToSaveResponse(this.lectureService.update(this.lecture)); + if (this.lecture().id !== undefined) { + this.subscribeToSaveResponse(this.lectureService.update(this.lecture())); } else { // Newly created lectures must have a channel name, which cannot be undefined - this.subscribeToSaveResponse(this.lectureService.create(this.lecture)); + this.subscribeToSaveResponse(this.lectureService.create(this.lecture())); } } @@ -139,7 +213,7 @@ export class LectureUpdateComponent implements OnInit { } /** - * @callback Callback function after saving a lecture, handles appropriate action in case of error + * @callback callback after saving a lecture, handles appropriate action in case of error * @param result The Http response from the server */ protected subscribeToSaveResponse(result: Observable>) { @@ -153,11 +227,11 @@ export class LectureUpdateComponent implements OnInit { * Action on successful lecture creation or edit */ protected onSaveSuccess(lecture: Lecture) { - if (this.isShowingWizardMode && !this.lecture.id) { + if (this.isShowingWizardMode && !this.lecture().id) { this.lectureService.findWithDetails(lecture.id!).subscribe({ next: (response: HttpResponse) => { this.isSaving = false; - this.lecture = response.body!; + this.lecture.set(response.body!); this.alertService.success(`Lecture with title ${lecture.title} was successfully created.`); this.wizardComponent.onLectureCreationSucceeded(); }, @@ -165,7 +239,7 @@ export class LectureUpdateComponent implements OnInit { } else if (this.processUnitMode) { this.isSaving = false; this.isProcessing = false; - this.alertService.success(`Lecture with title ${lecture.title} was successfully ${this.lecture.id !== undefined ? 'updated' : 'created'}.`); + this.alertService.success(`Lecture with title ${lecture.title} was successfully ${this.lecture().id !== undefined ? 'updated' : 'created'}.`); this.router.navigate(['course-management', lecture.course!.id, 'lectures', lecture.id, 'unit-management', 'attachment-units', 'process'], { state: { file: this.file, fileName: this.fileName }, }); @@ -181,6 +255,7 @@ export class LectureUpdateComponent implements OnInit { */ protected onSaveError(errorRes: HttpErrorResponse) { this.isSaving = false; + if (errorRes.error && errorRes.error.title) { this.alertService.addErrorAlert(errorRes.error.title, errorRes.error.message, errorRes.error.params); } else { @@ -189,18 +264,18 @@ export class LectureUpdateComponent implements OnInit { } onDatesValuesChanged() { - const startDate = this.lecture.startDate; - const endDate = this.lecture.endDate; - const visibleDate = this.lecture.visibleDate; + const startDate = this.lecture().startDate; + const endDate = this.lecture().endDate; + const visibleDate = this.lecture().visibleDate; // Prevent endDate from being before startDate, if both dates are set if (endDate && startDate?.isAfter(endDate)) { - this.lecture.endDate = startDate.clone(); + this.lecture().endDate = startDate.clone(); } // Prevent visibleDate from being after startDate, if both dates are set if (visibleDate && startDate?.isBefore(visibleDate)) { - this.lecture.visibleDate = startDate.clone(); + this.lecture().visibleDate = startDate.clone(); } } } diff --git a/src/main/webapp/app/lecture/lecture.module.ts b/src/main/webapp/app/lecture/lecture.module.ts index 5cdad5fc5fa2..db1380abd69d 100644 --- a/src/main/webapp/app/lecture/lecture.module.ts +++ b/src/main/webapp/app/lecture/lecture.module.ts @@ -16,7 +16,6 @@ import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; import { LectureImportComponent } from 'app/lecture/lecture-import.component'; import { ArtemisCompetenciesModule } from 'app/course/competencies/competency.module'; import { LectureUpdateWizardTitleComponent } from 'app/lecture/wizard-mode/lecture-wizard-title.component'; -import { LectureUpdateWizardPeriodComponent } from 'app/lecture/wizard-mode/lecture-wizard-period.component'; import { LectureUpdateWizardAttachmentsComponent } from 'app/lecture/wizard-mode/lecture-wizard-attachments.component'; import { LectureUpdateWizardUnitsComponent } from 'app/lecture/wizard-mode/lecture-wizard-units.component'; import { LectureUpdateWizardStepComponent } from 'app/lecture/wizard-mode/lecture-update-wizard-step.component'; @@ -24,6 +23,7 @@ import { TitleChannelNameModule } from 'app/shared/form/title-channel-name/title import { LectureTitleChannelNameComponent } from 'app/lecture/lecture-title-channel-name.component'; import { DetailModule } from 'app/detail-overview-list/detail.module'; import { CompetencyFormComponent } from 'app/course/competencies/forms/competency/competency-form.component'; +import { LectureUpdatePeriodComponent } from 'app/lecture/lecture-period/lecture-period.component'; const ENTITY_STATES = [...lectureRoute]; @@ -49,7 +49,7 @@ const ENTITY_STATES = [...lectureRoute]; LectureUpdateWizardComponent, LectureAttachmentsComponent, LectureUpdateWizardTitleComponent, - LectureUpdateWizardPeriodComponent, + LectureUpdatePeriodComponent, LectureUpdateWizardAttachmentsComponent, LectureUpdateWizardUnitsComponent, LectureUpdateWizardStepComponent, diff --git a/src/main/webapp/app/lecture/lecture.route.ts b/src/main/webapp/app/lecture/lecture.route.ts index 11850d96840f..aa36380de4a1 100644 --- a/src/main/webapp/app/lecture/lecture.route.ts +++ b/src/main/webapp/app/lecture/lecture.route.ts @@ -17,6 +17,7 @@ import { CourseManagementTabBarComponent } from 'app/course/manage/course-manage import { PdfPreviewComponent } from 'app/lecture/pdf-preview/pdf-preview.component'; import { Attachment } from 'app/entities/attachment.model'; import { AttachmentService } from 'app/lecture/attachment.service'; +import { hasLectureUnsavedChangesGuard } from './hasLectureUnsavedChanges.guard'; @Injectable({ providedIn: 'root' }) export class LectureResolve implements Resolve { @@ -132,6 +133,7 @@ export const lectureRoute: Routes = [ pageTitle: 'global.generic.edit', }, canActivate: [UserRouteAccessService], + canDeactivate: [hasLectureUnsavedChangesGuard], }, ...lectureUnitRoute, ], diff --git a/src/main/webapp/app/lecture/wizard-mode/lecture-update-wizard.component.html b/src/main/webapp/app/lecture/wizard-mode/lecture-update-wizard.component.html index cb704901206e..1e228e1c0747 100644 --- a/src/main/webapp/app/lecture/wizard-mode/lecture-update-wizard.component.html +++ b/src/main/webapp/app/lecture/wizard-mode/lecture-update-wizard.component.html @@ -3,7 +3,7 @@ } @if (currentStep >= LECTURE_UPDATE_WIZARD_PERIOD_STEP) { - + } @if (currentStep >= LECTURE_UPDATE_WIZARD_ATTACHMENT_STEP) { diff --git a/src/main/webapp/app/lecture/wizard-mode/lecture-wizard-period.component.ts b/src/main/webapp/app/lecture/wizard-mode/lecture-wizard-period.component.ts deleted file mode 100644 index 7d9b48571621..000000000000 --- a/src/main/webapp/app/lecture/wizard-mode/lecture-wizard-period.component.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Component, Input } from '@angular/core'; -import { Lecture } from 'app/entities/lecture.model'; - -@Component({ - selector: 'jhi-lecture-update-wizard-period', - templateUrl: './lecture-wizard-period.component.html', -}) -export class LectureUpdateWizardPeriodComponent { - @Input() currentStep: number; - @Input() lecture: Lecture; - @Input() validateDatesFunction: () => void; -} diff --git a/src/main/webapp/app/shared/form/title-channel-name/title-channel-name.component.ts b/src/main/webapp/app/shared/form/title-channel-name/title-channel-name.component.ts index 269e3a7e84e4..b7f139bc5182 100644 --- a/src/main/webapp/app/shared/form/title-channel-name/title-channel-name.component.ts +++ b/src/main/webapp/app/shared/form/title-channel-name/title-channel-name.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild, computed, effect, input, signal, viewChild } from '@angular/core'; +import { AfterViewInit, Component, Input, OnDestroy, OnInit, ViewChild, computed, effect, input, output, signal, viewChild } from '@angular/core'; import { ControlContainer, NgForm, NgModel } from '@angular/forms'; import { Subject, Subscription } from 'rxjs'; import { ProgrammingExerciseInputField } from 'app/exercises/programming/manage/update/programming-exercise-update.helper'; @@ -26,8 +26,8 @@ export class TitleChannelNameComponent implements AfterViewInit, OnDestroy, OnIn @ViewChild('field_title') field_title: NgModel; field_channel_name = viewChild('field_channel_name'); - @Output() titleChange = new EventEmitter(); - @Output() channelNameChange = new EventEmitter(); + titleChange = output(); + channelNameChange = output(); isFormValidSignal = signal(false); /** diff --git a/src/main/webapp/i18n/de/lecture.json b/src/main/webapp/i18n/de/lecture.json index e5ecd6649b38..ede4e7eaeb02 100644 --- a/src/main/webapp/i18n/de/lecture.json +++ b/src/main/webapp/i18n/de/lecture.json @@ -80,9 +80,7 @@ "attachmentsStepTitle": "Anhänge", "attachmentsStepMessage": "Lade Anhänge für die Vorlesung hoch.", "unitsStepTitle": "Vorlesungseinheiten", - "unitsStepMessage": "Füge Inhalte zur Vorlesung hinzu durch Erstellung von Vorlesungseinheiten.", - "competenciesStepTitle": "Kompetenzen", - "competenciesStepMessage": "Verknüpfe die Einheiten dieser Vorlesung mit Kompetenzen, um zu zeigen, welche Kompetenzen Studierende erreichen werden, wenn sie die Einheit abschließen." + "unitsStepMessage": "Füge Inhalte zur Vorlesung hinzu, indem du Vorlesungseinheiten erstellst." }, "newLectureUnit": "Neue Vorlesungseinheit", "editLectureUnit": "Vorlesungseinheit bearbeiten", @@ -91,6 +89,12 @@ "competencyTitle": "Titel", "competencyConnectedUnits": "Verknüpfte Einheiten", "competencyNoConnectedUnits": "Keine verknüpften Einheiten" + }, + "dismissChangesModal": { + "title": "Ungespeicherte Änderungen der Vorlesung verwerfen?", + "message": "Bist du sicher, dass du die ungespeicherten Änderungen verwerfen willst?", + "sectionsThatContainUnsavedChangesSingular": "Der folgende Abschnitt enthält ungespeicherte Änderungen:", + "sectionsThatContainUnsavedChangesPlural": "Die folgenden Abschnitte enthalten ungespeicherte Änderungen:" } }, "attachment": { diff --git a/src/main/webapp/i18n/en/lecture.json b/src/main/webapp/i18n/en/lecture.json index 284c7d83ace3..d93f7e5f5779 100644 --- a/src/main/webapp/i18n/en/lecture.json +++ b/src/main/webapp/i18n/en/lecture.json @@ -80,9 +80,7 @@ "attachmentsStepTitle": "Attachments", "attachmentsStepMessage": "Upload attachments to this lecture.", "unitsStepTitle": "Units", - "unitsStepMessage": "Add content to the lecture by creating different kinds of lecture units.", - "competenciesStepTitle": "Competencies", - "competenciesStepMessage": "Make it easily visible what knowledge students will achieve when completing the units of this lecture by connecting them to competencies." + "unitsStepMessage": "Add content to the lecture by creating different kinds of lecture units." }, "newLectureUnit": "New Lecture Unit", "editLectureUnit": "Edit Lecture Unit", @@ -91,6 +89,12 @@ "competencyTitle": "Title", "competencyConnectedUnits": "Connected Units", "competencyNoConnectedUnits": "No connected units" + }, + "dismissChangesModal": { + "title": "Discard unsaved lecture changes", + "message": "Are you sure you want to discard your unsaved changes?", + "sectionsThatContainUnsavedChangesSingular": "The following section contains unsaved changes:", + "sectionsThatContainUnsavedChangesPlural": "The following sections contain unsaved changes:" } }, "attachment": { diff --git a/src/test/javascript/spec/component/lecture/close-edit-lecture-modal.component.spec.ts b/src/test/javascript/spec/component/lecture/close-edit-lecture-modal.component.spec.ts new file mode 100644 index 000000000000..93b92f1d9627 --- /dev/null +++ b/src/test/javascript/spec/component/lecture/close-edit-lecture-modal.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArtemisTestModule } from '../../test.module'; +import { CloseEditLectureModalComponent } from '../../../../../main/webapp/app/lecture/close-edit-lecture-dialog/close-edit-lecture-modal.component'; + +describe('CloseEditLectureModalComponent', () => { + let component: CloseEditLectureModalComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ArtemisTestModule, CloseEditLectureModalComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CloseEditLectureModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/test/javascript/spec/component/lecture/hasLectureUnsavedChanges.guard.spec.ts b/src/test/javascript/spec/component/lecture/hasLectureUnsavedChanges.guard.spec.ts new file mode 100644 index 000000000000..8c2b66b14ada --- /dev/null +++ b/src/test/javascript/spec/component/lecture/hasLectureUnsavedChanges.guard.spec.ts @@ -0,0 +1,95 @@ +import { ActivatedRouteSnapshot, GuardResult, MaybeAsync, Router, RouterStateSnapshot } from '@angular/router'; +import { hasLectureUnsavedChangesGuard } from '../../../../../main/webapp/app/lecture/hasLectureUnsavedChanges.guard'; +import { LectureUpdateComponent } from '../../../../../main/webapp/app/lecture/lecture-update.component'; +import { TestBed } from '@angular/core/testing'; +import { MockRouter } from '../../helpers/mocks/mock-router'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { Observable, firstValueFrom, of } from 'rxjs'; +import { MockNgbModalService } from '../../helpers/mocks/service/mock-ngb-modal.service'; + +describe('hasLectureUnsavedChanges', () => { + let component: LectureUpdateComponent; + let currentRoute: ActivatedRouteSnapshot; + let currentState: RouterStateSnapshot; + let nextState: RouterStateSnapshot; + let mockNgbModal: NgbModal; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [LectureUpdateComponent], + providers: [ + { provide: Router, useClass: MockRouter }, + { provide: NgbModal, useClass: MockNgbModalService }, + { + provide: LectureUpdateComponent, + useValue: { + shouldDisplayDismissWarning: true, + isShowingWizardMode: false, + isChangeMadeToTitleSection: jest.fn().mockReturnValue(true), + isChangeMadeToPeriodSection: jest.fn().mockReturnValue(true), + isChangeMadeToTitleOrPeriodSection: true, + }, + }, + ], + }).compileComponents(); + + component = TestBed.inject(LectureUpdateComponent); + mockNgbModal = TestBed.inject(NgbModal); + const mockModalRef = { + componentInstance: {}, + result: Promise.resolve(true), + }; + jest.spyOn(mockNgbModal, 'open').mockReturnValue(mockModalRef as NgbModalRef); + + currentRoute = {} as ActivatedRouteSnapshot; + currentState = {} as RouterStateSnapshot; + nextState = {} as RouterStateSnapshot; + }); + + it('should return true if warning is not bypassed by shouldDisplayDismissWarning variable but no changes were made', async () => { + component.shouldDisplayDismissWarning = true; + component.isChangeMadeToTitleOrPeriodSection = false; + + const result = await firstValueFrom(getGuardResultAsObservable(hasLectureUnsavedChangesGuard(component, currentRoute, currentState, nextState))); + expect(result).toBeTrue(); + }); + + it('should return true if dismiss warning shall not be displayed', async () => { + component.shouldDisplayDismissWarning = false; + component.isChangeMadeToTitleOrPeriodSection = true; + + const result = await firstValueFrom(getGuardResultAsObservable(hasLectureUnsavedChangesGuard(component, currentRoute, currentState, nextState))); + expect(result).toBeTrue(); + }); + + it('should return result from modal (true, dismiss changes)', async () => { + component.shouldDisplayDismissWarning = true; + + const result = await TestBed.runInInjectionContext(() => { + return firstValueFrom(getGuardResultAsObservable(hasLectureUnsavedChangesGuard(component, currentRoute, currentState, nextState))); + }); + + expect(result).toBeTrue(); + }); + + it('should return result from modal (false, keep editing)', async () => { + component.shouldDisplayDismissWarning = true; + + const mockModalRef = { + componentInstance: {}, + result: Promise.resolve(false), + }; + jest.spyOn(mockNgbModal, 'open').mockReturnValue(mockModalRef as NgbModalRef); + + const result = await TestBed.runInInjectionContext(() => { + return firstValueFrom(getGuardResultAsObservable(hasLectureUnsavedChangesGuard(component, currentRoute, currentState, nextState))); + }); + + expect(result).toBeFalse(); + }); + + function getGuardResultAsObservable(guardResult: MaybeAsync): Observable> { + return guardResult instanceof Observable ? guardResult : of(guardResult); + } +}); diff --git a/src/test/javascript/spec/component/lecture/wizard-mode/lecture-wizard-period.component.spec.ts b/src/test/javascript/spec/component/lecture/lecture-period.component.spec.ts similarity index 54% rename from src/test/javascript/spec/component/lecture/wizard-mode/lecture-wizard-period.component.spec.ts rename to src/test/javascript/spec/component/lecture/lecture-period.component.spec.ts index f06067251d59..1f6309915829 100644 --- a/src/test/javascript/spec/component/lecture/wizard-mode/lecture-wizard-period.component.spec.ts +++ b/src/test/javascript/spec/component/lecture/lecture-period.component.spec.ts @@ -1,27 +1,28 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormDateTimePickerComponent } from 'app/shared/date-time-picker/date-time-picker.component'; +import { FormDateTimePickerComponent } from '../../../../../main/webapp/app/shared/date-time-picker/date-time-picker.component'; import { MockComponent, MockPipe } from 'ng-mocks'; -import { Lecture } from 'app/entities/lecture.model'; -import { LectureUpdateWizardPeriodComponent } from 'app/lecture/wizard-mode/lecture-wizard-period.component'; -import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { Lecture } from '../../../../../main/webapp/app/entities/lecture.model'; +import { ArtemisTranslatePipe } from '../../../../../main/webapp/app/shared/pipes/artemis-translate.pipe'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { LectureUpdatePeriodComponent } from '../../../../../main/webapp/app/lecture/lecture-period/lecture-period.component'; describe('LectureWizardPeriodComponent', () => { - let wizardPeriodComponentFixture: ComponentFixture; - let wizardPeriodComponent: LectureUpdateWizardPeriodComponent; + let wizardPeriodComponentFixture: ComponentFixture; + let wizardPeriodComponent: LectureUpdatePeriodComponent; beforeEach(() => { TestBed.configureTestingModule({ imports: [ReactiveFormsModule, FormsModule], - declarations: [LectureUpdateWizardPeriodComponent, MockPipe(ArtemisTranslatePipe), MockComponent(FormDateTimePickerComponent)], + declarations: [LectureUpdatePeriodComponent, MockPipe(ArtemisTranslatePipe), MockComponent(FormDateTimePickerComponent)], providers: [], schemas: [], }) .compileComponents() .then(() => { - wizardPeriodComponentFixture = TestBed.createComponent(LectureUpdateWizardPeriodComponent); + wizardPeriodComponentFixture = TestBed.createComponent(LectureUpdatePeriodComponent); wizardPeriodComponent = wizardPeriodComponentFixture.componentInstance; - wizardPeriodComponent.lecture = new Lecture(); + + wizardPeriodComponentFixture.componentRef.setInput('lecture', new Lecture()); }); }); diff --git a/src/test/javascript/spec/component/lecture/lecture-update.component.spec.ts b/src/test/javascript/spec/component/lecture/lecture-update.component.spec.ts index 03be222b4cb4..384ec2002b9c 100644 --- a/src/test/javascript/spec/component/lecture/lecture-update.component.spec.ts +++ b/src/test/javascript/spec/component/lecture/lecture-update.component.spec.ts @@ -14,7 +14,7 @@ import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { HtmlForMarkdownPipe } from 'app/shared/pipes/html-for-markdown.pipe'; import dayjs from 'dayjs/esm'; -import { MockComponent, MockModule, MockPipe } from 'ng-mocks'; +import { MockComponent, MockDirective, MockModule, MockPipe } from 'ng-mocks'; import { of } from 'rxjs'; import { MockRouterLinkDirective } from '../../helpers/mocks/directive/mock-router-link.directive'; import { MockRouter } from '../../helpers/mocks/mock-router'; @@ -23,6 +23,11 @@ import { ArtemisTestModule } from '../../test.module'; import { DocumentationButtonComponent } from 'app/shared/components/documentation-button/documentation-button.component'; import { LectureTitleChannelNameComponent } from 'app/lecture/lecture-title-channel-name.component'; import { MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; +import { CustomNotIncludedInValidatorDirective } from '../../../../../main/webapp/app/shared/validators/custom-not-included-in-validator.directive'; +import { OwlDateTimeModule } from '@danielmoncada/angular-datetime-picker'; +import { TitleChannelNameComponent } from '../../../../../main/webapp/app/shared/form/title-channel-name/title-channel-name.component'; +import { LectureUpdatePeriodComponent } from '../../../../../main/webapp/app/lecture/lecture-period/lecture-period.component'; +import { LectureUnitManagementComponent } from '../../../../../main/webapp/app/lecture/lecture-unit/lecture-unit-management/lecture-unit-management.component'; describe('LectureUpdateComponent', () => { let lectureUpdateWizardComponentFixture: ComponentFixture; @@ -45,18 +50,22 @@ describe('LectureUpdateComponent', () => { pastLecture.endDate = yesterday; TestBed.configureTestingModule({ - imports: [ArtemisTestModule, FormsModule, MockModule(NgbTooltipModule)], + imports: [ArtemisTestModule, FormsModule, MockModule(NgbTooltipModule), MockModule(OwlDateTimeModule)], declarations: [ LectureUpdateComponent, - MockComponent(LectureTitleChannelNameComponent), + LectureTitleChannelNameComponent, + TitleChannelNameComponent, + FormDateTimePickerComponent, + LectureUpdatePeriodComponent, MockComponent(LectureUpdateWizardComponent), - MockComponent(FormDateTimePickerComponent), + MockComponent(LectureUnitManagementComponent), MockComponent(MarkdownEditorMonacoComponent), MockComponent(DocumentationButtonComponent), MockPipe(ArtemisTranslatePipe), MockPipe(ArtemisDatePipe), MockPipe(HtmlForMarkdownPipe), MockRouterLinkDirective, + MockDirective(CustomNotIncludedInValidatorDirective), ], providers: [ { provide: TranslateService, useClass: MockTranslateService }, @@ -97,8 +106,8 @@ describe('LectureUpdateComponent', () => { jest.restoreAllMocks(); }); - it('should create lecture', fakeAsync(() => { - lectureUpdateComponent.lecture = { title: 'test1', channelName: 'test1' } as Lecture; + it('should create lecture', () => { + lectureUpdateComponent.lecture.set({ title: 'test1', channelName: 'test1' } as Lecture); const navigateSpy = jest.spyOn(router, 'navigate'); const createSpy = jest.spyOn(lectureService, 'create').mockReturnValue( @@ -116,7 +125,6 @@ describe('LectureUpdateComponent', () => { ); lectureUpdateComponent.save(); - tick(); lectureUpdateComponentFixture.detectChanges(); const expectedPath = ['course-management', 1, 'lectures', 3]; @@ -124,10 +132,10 @@ describe('LectureUpdateComponent', () => { expect(createSpy).toHaveBeenCalledOnce(); expect(createSpy).toHaveBeenCalledWith({ title: 'test1', channelName: 'test1' }); - })); + }); it('should create lecture in wizard mode', () => { - lectureUpdateComponent.lecture = { title: '', channelName: '' } as Lecture; + lectureUpdateComponent.lecture.set({ title: '', channelName: '' } as Lecture); lectureUpdateComponent.isShowingWizardMode = true; lectureUpdateComponent.wizardComponent = lectureUpdateWizardComponent; @@ -173,7 +181,7 @@ describe('LectureUpdateComponent', () => { activatedRoute.parent!.data = of({ course: { id: 1 }, lecture: { id: 6 } }); lectureUpdateComponentFixture.detectChanges(); - lectureUpdateComponent.lecture = { id: 6, title: 'test1Updated', channelName: 'test1Updated' } as Lecture; + lectureUpdateComponent.lecture.set({ id: 6, title: 'test1Updated', channelName: 'test1Updated' } as Lecture); const updateSpy = jest.spyOn(lectureService, 'update').mockReturnValue( of>( @@ -249,7 +257,7 @@ describe('LectureUpdateComponent', () => { lectureUpdateComponent.file = new File([''], 'testFile.pdf', { type: 'application/pdf' }); lectureUpdateComponent.fileName = 'testFile'; lectureUpdateComponent.processUnitMode = true; - lectureUpdateComponent.lecture = { title: 'test1', channelName: 'test1' } as Lecture; + lectureUpdateComponent.lecture.set({ title: 'test1', channelName: 'test1' } as Lecture); const navigateSpy = jest.spyOn(router, 'navigate'); const createSpy = jest.spyOn(lectureService, 'create').mockReturnValue( @@ -279,7 +287,7 @@ describe('LectureUpdateComponent', () => { expect(navigateSpy).toHaveBeenCalledWith(expectedPath, { state: { file: lectureUpdateComponent.file, fileName: lectureUpdateComponent.fileName } }); })); - it('should call onFileChange on changed file', fakeAsync(() => { + it('should call onFileChange on changed file', () => { lectureUpdateComponent.processUnitMode = false; lectureUpdateComponentFixture.detectChanges(); expect(lectureUpdateComponentFixture.debugElement.nativeElement.querySelector('#fileInput')).toBeFalsy(); @@ -295,52 +303,117 @@ describe('LectureUpdateComponent', () => { expect(lectureUpdateComponentFixture.debugElement.nativeElement.querySelector('#fileInput')).toBeTruthy(); fileInput.dispatchEvent(new Event('change')); expect(onFileChangeStub).toHaveBeenCalledOnce(); - })); + }); it('should set lecture visible date, start date and end date correctly', fakeAsync(() => { activatedRoute = TestBed.inject(ActivatedRoute); activatedRoute.parent!.data = of({ course: { id: 1 }, lecture: { id: 6 } }); lectureUpdateComponentFixture.detectChanges(); - lectureUpdateComponent.lecture = { id: 6, title: 'test1Updated' } as Lecture; + lectureUpdateComponent.lecture.set({ id: 6, title: 'test1Updated' } as Lecture); const setDatesSpy = jest.spyOn(lectureUpdateComponent, 'onDatesValuesChanged'); - lectureUpdateComponent.lecture.visibleDate = dayjs().year(2022).month(3).date(7); - lectureUpdateComponent.lecture.startDate = dayjs().year(2022).month(3).date(5); - lectureUpdateComponent.lecture.endDate = dayjs().year(2022).month(3).date(1); + lectureUpdateComponent.lecture().visibleDate = dayjs().year(2022).month(3).date(7); + lectureUpdateComponent.lecture().startDate = dayjs().year(2022).month(3).date(5); + lectureUpdateComponent.lecture().endDate = dayjs().year(2022).month(3).date(1); lectureUpdateComponent.onDatesValuesChanged(); expect(setDatesSpy).toHaveBeenCalledOnce(); - expect(lectureUpdateComponent.lecture.startDate).toEqual(lectureUpdateComponent.lecture.endDate); - expect(lectureUpdateComponent.lecture.startDate).toEqual(lectureUpdateComponent.lecture.visibleDate); + expect(lectureUpdateComponent.lecture().startDate).toEqual(lectureUpdateComponent.lecture().endDate); + expect(lectureUpdateComponent.lecture().startDate).toEqual(lectureUpdateComponent.lecture().visibleDate); lectureUpdateComponentFixture.detectChanges(); tick(); - lectureUpdateComponent.lecture.startDate = undefined; - lectureUpdateComponent.lecture.endDate = undefined; - lectureUpdateComponent.lecture.visibleDate = undefined; + lectureUpdateComponent.lecture().startDate = undefined; + lectureUpdateComponent.lecture().endDate = undefined; + lectureUpdateComponent.lecture().visibleDate = undefined; lectureUpdateComponent.onDatesValuesChanged(); expect(setDatesSpy).toHaveBeenCalledTimes(2); - expect(lectureUpdateComponent.lecture.startDate).toBeUndefined(); - expect(lectureUpdateComponent.lecture.endDate).toBeUndefined(); - expect(lectureUpdateComponent.lecture.visibleDate).toBeUndefined(); + expect(lectureUpdateComponent.lecture().startDate).toBeUndefined(); + expect(lectureUpdateComponent.lecture().endDate).toBeUndefined(); + expect(lectureUpdateComponent.lecture().visibleDate).toBeUndefined(); lectureUpdateComponentFixture.detectChanges(); tick(); - lectureUpdateComponent.lecture.visibleDate = dayjs().year(2022).month(1).date(1); - lectureUpdateComponent.lecture.startDate = dayjs().year(2022).month(1).date(2); - lectureUpdateComponent.lecture.endDate = dayjs().year(2022).month(1).date(3); + lectureUpdateComponent.lecture().visibleDate = dayjs().year(2022).month(1).date(1); + lectureUpdateComponent.lecture().startDate = dayjs().year(2022).month(1).date(2); + lectureUpdateComponent.lecture().endDate = dayjs().year(2022).month(1).date(3); lectureUpdateComponent.onDatesValuesChanged(); expect(setDatesSpy).toHaveBeenCalledTimes(3); - expect(lectureUpdateComponent.lecture.visibleDate.toDate()).toBeBefore(lectureUpdateComponent.lecture.startDate.toDate()); - expect(lectureUpdateComponent.lecture.startDate.toDate()).toBeBefore(lectureUpdateComponent.lecture.endDate.toDate()); + if (lectureUpdateComponent.lecture().visibleDate && lectureUpdateComponent.lecture().startDate) { + expect(lectureUpdateComponent.lecture().visibleDate!.toDate()).toBeBefore(lectureUpdateComponent.lecture().startDate!.toDate()); + } else { + throw new Error('visibleDate and startDate should not be undefined'); + } + + if (lectureUpdateComponent.lecture().startDate && lectureUpdateComponent.lecture().endDate) { + expect(lectureUpdateComponent.lecture().startDate!.toDate()).toBeBefore(lectureUpdateComponent.lecture().endDate!.toDate()); + } else { + throw new Error('startDate and endDate should not be undefined'); + } })); + + describe('isChangeMadeToTitleSection', () => { + it('should detect changes made to the title section', () => { + lectureUpdateComponent.lecture.set({ title: 'new title', channelName: 'new channel', description: 'new description' } as Lecture); + lectureUpdateComponent.lectureOnInit = { title: 'old title', channelName: 'old channel', description: 'old description' } as Lecture; + expect(lectureUpdateComponent.isChangeMadeToTitleSection()).toBeTrue(); + + lectureUpdateComponent.lecture.set({ + title: lectureUpdateComponent.lectureOnInit.title, + channelName: lectureUpdateComponent.lectureOnInit.channelName, + description: lectureUpdateComponent.lectureOnInit.description, + } as Lecture); + expect(lectureUpdateComponent.isChangeMadeToTitleSection()).toBeFalse(); + }); + + it('should handle undefined from description properly', () => { + lectureUpdateComponent.lecture.set({ title: 'new title', channelName: 'new channel', description: 'new description' } as Lecture); + lectureUpdateComponent.lectureOnInit = { title: 'old title', channelName: 'old channel', description: undefined } as Lecture; + expect(lectureUpdateComponent.isChangeMadeToTitleSection()).toBeTrue(); + + lectureUpdateComponent.lecture.set({ + title: lectureUpdateComponent.lectureOnInit.title, + channelName: lectureUpdateComponent.lectureOnInit.channelName, + description: '', // will be an empty string if the user clears the input, but was loaded with undefined in that case + } as Lecture); + expect(lectureUpdateComponent.isChangeMadeToTitleSection()).toBeFalse(); + }); + }); + + describe('isChangeMadeToPeriodSection', () => { + it('should detect changes made to the period section', () => { + lectureUpdateComponent.lecture.set({ visibleDate: dayjs().add(1, 'day'), startDate: dayjs().add(2, 'day'), endDate: dayjs().add(3, 'day') } as Lecture); + lectureUpdateComponent.lectureOnInit = { visibleDate: dayjs(), startDate: dayjs(), endDate: dayjs() } as Lecture; + expect(lectureUpdateComponent.isChangeMadeToPeriodSection()).toBeTrue(); + + lectureUpdateComponent.lecture.set({ + visibleDate: lectureUpdateComponent.lectureOnInit.visibleDate, + startDate: lectureUpdateComponent.lectureOnInit.startDate, + endDate: lectureUpdateComponent.lectureOnInit.endDate, + } as Lecture); + expect(lectureUpdateComponent.isChangeMadeToPeriodSection()).toBeFalse(); + }); + + it('should not consider resetting an undefined date as a change', () => { + lectureUpdateComponent.lecture.set({ visibleDate: dayjs().add(1, 'day'), startDate: dayjs().add(2, 'day'), endDate: dayjs().add(3, 'day') } as Lecture); + lectureUpdateComponent.lectureOnInit = { visibleDate: undefined, startDate: undefined, endDate: undefined } as Lecture; + expect(lectureUpdateComponent.isChangeMadeToPeriodSection()).toBeTrue(); + + lectureUpdateComponent.lecture.set({ + visibleDate: dayjs('undefined'), + startDate: dayjs('undefined'), + endDate: dayjs('undefined'), + } as Lecture); + expect(lectureUpdateComponent.isChangeMadeToPeriodSection()).toBeFalse(); + }); + }); }); diff --git a/src/test/javascript/spec/component/lecture/wizard-mode/lecture-wizard-title.component.spec.ts b/src/test/javascript/spec/component/lecture/wizard-mode/lecture-wizard-title.component.spec.ts index 36701004edbb..de4b7fc35dc7 100644 --- a/src/test/javascript/spec/component/lecture/wizard-mode/lecture-wizard-title.component.spec.ts +++ b/src/test/javascript/spec/component/lecture/wizard-mode/lecture-wizard-title.component.spec.ts @@ -1,10 +1,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { LectureUpdateWizardTitleComponent } from 'app/lecture/wizard-mode/lecture-wizard-title.component'; import { Lecture } from 'app/entities/lecture.model'; -import { MockComponent } from 'ng-mocks'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { LectureTitleChannelNameComponent } from 'app/lecture/lecture-title-channel-name.component'; -import { MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; +import { MockComponent, MockDirective, MockModule } from 'ng-mocks'; +import { FormsModule } from '@angular/forms'; +import { MarkdownEditorMonacoComponent } from '../../../../../../main/webapp/app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; +import { LectureTitleChannelNameComponent } from '../../../../../../main/webapp/app/lecture/lecture-title-channel-name.component'; +import { CustomNotIncludedInValidatorDirective } from '../../../../../../main/webapp/app/shared/validators/custom-not-included-in-validator.directive'; +import { TitleChannelNameComponent } from '../../../../../../main/webapp/app/shared/form/title-channel-name/title-channel-name.component'; describe('LectureWizardTitleComponent', () => { let wizardTitleComponentFixture: ComponentFixture; @@ -12,8 +14,14 @@ describe('LectureWizardTitleComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ReactiveFormsModule, FormsModule], - declarations: [LectureUpdateWizardTitleComponent, MockComponent(MarkdownEditorMonacoComponent), MockComponent(LectureTitleChannelNameComponent)], + imports: [MockModule(FormsModule)], + declarations: [ + LectureUpdateWizardTitleComponent, + LectureTitleChannelNameComponent, + TitleChannelNameComponent, + MockComponent(MarkdownEditorMonacoComponent), + MockDirective(CustomNotIncludedInValidatorDirective), + ], providers: [], schemas: [], }) diff --git a/src/test/javascript/spec/component/lecture/wizard-mode/lecture-wizard.component.spec.ts b/src/test/javascript/spec/component/lecture/wizard-mode/lecture-wizard.component.spec.ts index 716de13900a0..e01eea78ab89 100644 --- a/src/test/javascript/spec/component/lecture/wizard-mode/lecture-wizard.component.spec.ts +++ b/src/test/javascript/spec/component/lecture/wizard-mode/lecture-wizard.component.spec.ts @@ -1,5 +1,5 @@ -import { ComponentFixture, TestBed, fakeAsync } from '@angular/core/testing'; -import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { MockComponent, MockModule, MockProvider } from 'ng-mocks'; import { ActivatedRoute, Router } from '@angular/router'; import { MockRouter } from '../../../helpers/mocks/mock-router'; import { of } from 'rxjs'; @@ -7,18 +7,18 @@ import { Lecture } from 'app/entities/lecture.model'; import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; import { TranslateService } from '@ngx-translate/core'; import { Course } from 'app/entities/course.model'; -import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { LectureUpdateWizardComponent } from 'app/lecture/wizard-mode/lecture-update-wizard.component'; import { ArtemisNavigationUtilService } from 'app/utils/navigation.utils'; import { CourseManagementService } from 'app/course/manage/course-management.service'; import { LectureUpdateWizardStepComponent } from 'app/lecture/wizard-mode/lecture-update-wizard-step.component'; import { LectureUpdateWizardUnitsComponent } from 'app/lecture/wizard-mode/lecture-wizard-units.component'; import { LectureUpdateWizardAttachmentsComponent } from 'app/lecture/wizard-mode/lecture-wizard-attachments.component'; -import { LectureUpdateWizardPeriodComponent } from 'app/lecture/wizard-mode/lecture-wizard-period.component'; import { LectureUpdateWizardTitleComponent } from 'app/lecture/wizard-mode/lecture-wizard-title.component'; -import { TranslateDirective } from 'app/shared/language/translate.directive'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import dayjs from 'dayjs/esm'; +import { LectureUpdatePeriodComponent } from '../../../../../../main/webapp/app/lecture/lecture-period/lecture-period.component'; +import { ArtemisTestModule } from '../../../test.module'; +import { ArtemisSharedModule } from '../../../../../../main/webapp/app/shared/shared.module'; +import { FormDateTimePickerComponent } from '../../../../../../main/webapp/app/shared/date-time-picker/date-time-picker.component'; describe('LectureWizardComponent', () => { let wizardComponentFixture: ComponentFixture; @@ -26,17 +26,15 @@ describe('LectureWizardComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [], + imports: [ArtemisTestModule, MockModule(ArtemisSharedModule)], declarations: [ LectureUpdateWizardComponent, - MockPipe(ArtemisTranslatePipe), + LectureUpdatePeriodComponent, + MockComponent(FormDateTimePickerComponent), + MockComponent(LectureUpdateWizardTitleComponent), MockComponent(LectureUpdateWizardStepComponent), MockComponent(LectureUpdateWizardUnitsComponent), MockComponent(LectureUpdateWizardAttachmentsComponent), - MockComponent(LectureUpdateWizardPeriodComponent), - MockComponent(LectureUpdateWizardTitleComponent), - MockComponent(FaIconComponent), - MockDirective(TranslateDirective), ], providers: [ MockProvider(ArtemisNavigationUtilService), @@ -83,6 +81,8 @@ describe('LectureWizardComponent', () => { wizardComponentFixture.detectChanges(); expect(wizardComponent).not.toBeNull(); + tick(); + wizardComponentFixture.whenStable().then(() => { expect(wizardComponent.currentStep).toBe(1); }); @@ -95,6 +95,8 @@ describe('LectureWizardComponent', () => { wizardComponentFixture.detectChanges(); expect(wizardComponent).not.toBeNull(); + tick(); + wizardComponentFixture.whenStable().then(() => { expect(wizardComponent.currentStep).toBe(2); }); From d10a20cde78091af6d2a9b7ce81818bafd0c8782 Mon Sep 17 00:00:00 2001 From: Florian Glombik <63976129+florian-glombik@users.noreply.github.com> Date: Wed, 18 Dec 2024 15:07:37 +0100 Subject: [PATCH 11/12] Lectures: Remove guided mode exercise creation shortcut (#10022) --- ...course-management-exercises.component.html | 5 ---- .../course-management-exercises.component.ts | 30 ++----------------- .../file-upload-exercise-update.component.ts | 21 ++----------- .../modeling-exercise-update.component.ts | 16 +--------- .../manage/quiz-exercise-update.component.ts | 13 -------- .../text-exercise-update.component.ts | 13 -------- .../create-exercise-unit.component.html | 9 ------ .../create-exercise-unit.component.ts | 9 ------ .../lecture-wizard-units.component.html | 2 -- src/main/webapp/i18n/de/lecture.json | 1 - src/main/webapp/i18n/en/lecture.json | 1 - .../text-exercise-update.component.spec.ts | 1 - 12 files changed, 7 insertions(+), 114 deletions(-) diff --git a/src/main/webapp/app/course/manage/course-management-exercises.component.html b/src/main/webapp/app/course/manage/course-management-exercises.component.html index d267f2b04fa4..51f7c1ecfb10 100644 --- a/src/main/webapp/app/course/manage/course-management-exercises.component.html +++ b/src/main/webapp/app/course/manage/course-management-exercises.component.html @@ -10,11 +10,6 @@

- @if (showBackToWizardModeButton) { - - } diff --git a/src/main/webapp/app/course/manage/course-management-exercises.component.ts b/src/main/webapp/app/course/manage/course-management-exercises.component.ts index b22fa31fa819..70afc4bd3471 100644 --- a/src/main/webapp/app/course/manage/course-management-exercises.component.ts +++ b/src/main/webapp/app/course/manage/course-management-exercises.component.ts @@ -1,10 +1,8 @@ -import { Component, ContentChild, OnInit, TemplateRef } from '@angular/core'; +import { Component, ContentChild, OnInit, TemplateRef, inject } from '@angular/core'; import { Course } from 'app/entities/course.model'; -import { CourseManagementService } from './course-management.service'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { ExerciseFilter } from 'app/entities/exercise-filter.model'; import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; -import { faHandshakeAngle } from '@fortawesome/free-solid-svg-icons'; import { ExerciseType } from 'app/entities/exercise.model'; @Component({ @@ -28,22 +26,13 @@ export class CourseManagementExercisesComponent implements OnInit { filteredModelingExercisesCount = 0; filteredFileUploadExercisesCount = 0; exerciseFilter: ExerciseFilter; - showBackToWizardModeButton = false; - lectureIdForGoingBack: number; - lectureWizardStepForGoingBack: number; - - faHandshakeAngle = faHandshakeAngle; // extension points, see shared/extension-point @ContentChild('overrideGenerateAndImportButton') overrideGenerateAndImportButton: TemplateRef; @ContentChild('overrideProgrammingExerciseCard') overrideProgrammingExerciseCard: TemplateRef; @ContentChild('overrideNonProgrammingExerciseCard') overrideNonProgrammingExerciseCard: TemplateRef; - constructor( - private courseService: CourseManagementService, - private router: Router, - private route: ActivatedRoute, - ) {} + private readonly route = inject(ActivatedRoute); /** * initializes course @@ -55,12 +44,6 @@ export class CourseManagementExercisesComponent implements OnInit { } }); - this.route.queryParams.subscribe((params) => { - this.showBackToWizardModeButton = params.shouldHaveBackButtonToWizard; - this.lectureIdForGoingBack = params.lectureId; - this.lectureWizardStepForGoingBack = params.step; - }); - this.exerciseFilter = new ExerciseFilter(''); } @@ -104,11 +87,4 @@ export class CourseManagementExercisesComponent implements OnInit { shouldHideExerciseCard(type: string): boolean { return !['all', type].includes(this.exerciseFilter.exerciseTypeSearch); } - - goBackToWizardMode() { - this.router.navigate(['/course-management', this.course.id, 'lectures', this.lectureIdForGoingBack, 'edit'], { - queryParams: { shouldBeInWizardMode: 'true', shouldOpenCreateExercise: 'true', step: this.lectureWizardStepForGoingBack }, - queryParamsHandling: '', - }); - } } diff --git a/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-update.component.ts b/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-update.component.ts index 8fc4511e452e..798a1d50f57d 100644 --- a/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-update.component.ts +++ b/src/main/webapp/app/exercises/file-upload/manage/file-upload-exercise-update.component.ts @@ -33,6 +33,8 @@ import { FormulaAction } from 'app/shared/monaco-editor/model/actions/formula.ac changeDetection: ChangeDetectionStrategy.OnPush, }) export class FileUploadExerciseUpdateComponent implements AfterViewInit, OnDestroy, OnInit { + protected readonly faQuestionCircle = faQuestionCircle; + readonly IncludedInOverallScore = IncludedInOverallScore; readonly documentationType: DocumentationType = 'FileUpload'; @@ -50,7 +52,6 @@ export class FileUploadExerciseUpdateComponent implements AfterViewInit, OnDestr fileUploadExercise: FileUploadExercise; backupExercise: FileUploadExercise; isSaving: boolean; - goBackAfterSaving = false; exerciseCategories: ExerciseCategory[]; existingCategories: ExerciseCategory[]; notificationText?: string; @@ -59,19 +60,14 @@ export class FileUploadExerciseUpdateComponent implements AfterViewInit, OnDestr isImport: boolean; examCourseId?: number; - saveCommand: SaveExerciseCommand; - formStatusSections: FormSectionStatus[]; - // Subcriptions + // Subscriptions titleChannelNameComponentSubscription?: Subscription; pointsSubscription?: Subscription; bonusPointsSubscription?: Subscription; teamSubscription?: Subscription; - // Icons - faQuestionCircle = faQuestionCircle; - constructor( private fileUploadExerciseService: FileUploadExerciseService, private modalService: NgbModal, @@ -104,11 +100,6 @@ export class FileUploadExerciseUpdateComponent implements AfterViewInit, OnDestr this.examCourseId = getCourseId(fileUploadExercise); }); - this.activatedRoute.queryParams.subscribe((params) => { - if (params.shouldHaveBackButtonToWizard) { - this.goBackAfterSaving = true; - } - }); this.activatedRoute.url .pipe( tap( @@ -264,12 +255,6 @@ export class FileUploadExerciseUpdateComponent implements AfterViewInit, OnDestr private onSaveSuccess(exercise: Exercise) { this.isSaving = false; - if (this.goBackAfterSaving) { - this.navigationUtilService.navigateBack(); - - return; - } - this.navigationUtilService.navigateForwardFromExerciseUpdateOrCreation(exercise); } diff --git a/src/main/webapp/app/exercises/modeling/manage/modeling-exercise-update.component.ts b/src/main/webapp/app/exercises/modeling/manage/modeling-exercise-update.component.ts index 29612cd3c373..18faee221f26 100644 --- a/src/main/webapp/app/exercises/modeling/manage/modeling-exercise-update.component.ts +++ b/src/main/webapp/app/exercises/modeling/manage/modeling-exercise-update.component.ts @@ -1,5 +1,5 @@ import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; import { ModelingExercise } from 'app/entities/modeling-exercise.model'; import { ModelingExerciseService } from './modeling-exercise.service'; @@ -70,7 +70,6 @@ export class ModelingExerciseUpdateComponent implements AfterViewInit, OnDestroy isImport: boolean; isExamMode: boolean; semiAutomaticAssessmentAvailable = true; - goBackAfterSaving = false; formSectionStatus: FormSectionStatus[]; @@ -91,7 +90,6 @@ export class ModelingExerciseUpdateComponent implements AfterViewInit, OnDestroy private exerciseGroupService: ExerciseGroupService, private eventManager: EventManager, private activatedRoute: ActivatedRoute, - private router: Router, private navigationUtilService: ArtemisNavigationUtilService, private changeDetectorRef: ChangeDetectorRef, ) {} @@ -186,12 +184,6 @@ export class ModelingExerciseUpdateComponent implements AfterViewInit, OnDestroy ) .subscribe(); - this.activatedRoute.queryParams.subscribe((params) => { - if (params.shouldHaveBackButtonToWizard) { - this.goBackAfterSaving = true; - } - }); - this.isSaving = false; this.notificationText = undefined; } @@ -292,12 +284,6 @@ export class ModelingExerciseUpdateComponent implements AfterViewInit, OnDestroy this.eventManager.broadcast({ name: 'modelingExerciseListModification', content: 'OK' }); this.isSaving = false; - if (this.goBackAfterSaving) { - this.navigationUtilService.navigateBack(); - - return; - } - this.navigationUtilService.navigateForwardFromExerciseUpdateOrCreation(exercise); } diff --git a/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-update.component.ts b/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-update.component.ts index f212b3469e6c..0c03bd54d888 100644 --- a/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-update.component.ts +++ b/src/main/webapp/app/exercises/quiz/manage/quiz-exercise-update.component.ts @@ -52,7 +52,6 @@ export class QuizExerciseUpdateComponent extends QuizExerciseValidationDirective notificationText?: string; isImport = false; - goBackAfterSaving = false; /** Constants for 'Add existing questions' and 'Import file' features **/ showExistingQuestions = false; @@ -149,12 +148,6 @@ export class QuizExerciseUpdateComponent extends QuizExerciseValidationDirective this.isImport = true; } - this.route.queryParams.subscribe((params) => { - if (params.shouldHaveBackButtonToWizard) { - this.goBackAfterSaving = true; - } - }); - /** Query the courseService for the participationId given by the params */ if (this.courseId) { this.courseService.find(this.courseId).subscribe((response: HttpResponse) => { @@ -520,12 +513,6 @@ export class QuizExerciseUpdateComponent extends QuizExerciseValidationDirective this.savedEntity = cloneDeep(quizExercise); this.changeDetector.detectChanges(); - if (this.goBackAfterSaving) { - this.navigationUtilService.navigateBack(); - - return; - } - // Navigate back only if it's an import // If we edit the exercise, a user might just want to save the current state of the added quiz questions without going back if (this.isImport) { diff --git a/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-update.component.ts b/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-update.component.ts index 18a5e8131205..7d0b69df31a3 100644 --- a/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-update.component.ts +++ b/src/main/webapp/app/exercises/text/manage/text-exercise/text-exercise-update.component.ts @@ -67,7 +67,6 @@ export class TextExerciseUpdateComponent implements OnInit, OnDestroy, AfterView examCourseId?: number; isExamMode: boolean; isImport = false; - goBackAfterSaving = false; AssessmentType = AssessmentType; isAthenaEnabled$: Observable | undefined; @@ -170,12 +169,6 @@ export class TextExerciseUpdateComponent implements OnInit, OnDestroy, AfterView ) .subscribe(); - this.activatedRoute.queryParams.subscribe((params) => { - if (params.shouldHaveBackButtonToWizard) { - this.goBackAfterSaving = true; - } - }); - this.isAthenaEnabled$ = this.athenaService.isEnabled(); this.isSaving = false; @@ -278,12 +271,6 @@ export class TextExerciseUpdateComponent implements OnInit, OnDestroy, AfterView this.eventManager.broadcast({ name: 'textExerciseListModification', content: 'OK' }); this.isSaving = false; - if (this.goBackAfterSaving) { - this.navigationUtilService.navigateBack(); - - return; - } - this.navigationUtilService.navigateForwardFromExerciseUpdateOrCreation(exercise); } diff --git a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/create-exercise-unit/create-exercise-unit.component.html b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/create-exercise-unit/create-exercise-unit.component.html index f0f0076d89d3..2a3db2a5e78f 100644 --- a/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/create-exercise-unit/create-exercise-unit.component.html +++ b/src/main/webapp/app/lecture/lecture-unit/lecture-unit-management/create-exercise-unit/create-exercise-unit.component.html @@ -21,15 +21,6 @@

  } - @if (hasCreateExerciseButton()) { - - }