From 05448370788102525a02b06804d0aac40035abea Mon Sep 17 00:00:00 2001 From: Tim Cremer Date: Wed, 9 Oct 2024 11:46:55 +0200 Subject: [PATCH 01/23] First draft implementation: Propose FAQ as editor --- .../repository/FaqRepository.java | 3 + .../communication/web/FaqResource.java | 39 ++++++++++-- .../course-management-tab-bar.component.html | 2 +- .../course-management-card.component.html | 2 +- src/main/webapp/app/entities/faq.model.ts | 6 +- .../webapp/app/faq/faq-update.component.ts | 6 +- src/main/webapp/app/faq/faq.component.html | 62 +++++++++++++------ src/main/webapp/app/faq/faq.component.ts | 40 +++++++++++- src/main/webapp/app/faq/faq.service.ts | 14 +++++ .../course-faq/course-faq.component.ts | 4 +- src/main/webapp/i18n/de/faq.json | 14 +++-- src/main/webapp/i18n/en/faq.json | 14 +++-- 12 files changed, 166 insertions(+), 40 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/FaqRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/FaqRepository.java index bd8bb8989995..0361014a2076 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/FaqRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/FaqRepository.java @@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional; import de.tum.cit.aet.artemis.communication.domain.Faq; +import de.tum.cit.aet.artemis.communication.domain.FaqState; import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; /** @@ -30,6 +31,8 @@ public interface FaqRepository extends ArtemisJpaRepository { """) Set findAllCategoriesByCourseId(@Param("courseId") Long courseId); + Set findAllByCourseIdAndFaqState(Long courseId, FaqState faqState); + @Transactional @Modifying void deleteAllByCourseId(Long courseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java index 91a542aaa220..3a513afcc1d6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/FaqResource.java @@ -23,12 +23,14 @@ import org.springframework.web.bind.annotation.RestController; import de.tum.cit.aet.artemis.communication.domain.Faq; +import de.tum.cit.aet.artemis.communication.domain.FaqState; import de.tum.cit.aet.artemis.communication.dto.FaqDTO; import de.tum.cit.aet.artemis.communication.repository.FaqRepository; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.security.Role; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastEditor; 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.service.AuthorizationCheckService; @@ -72,7 +74,7 @@ public FaqResource(CourseRepository courseRepository, AuthorizationCheckService * @throws URISyntaxException if the Location URI syntax is incorrect */ @PostMapping("courses/{courseId}/faqs") - @EnforceAtLeastInstructor + @EnforceAtLeastEditor public ResponseEntity createFaq(@RequestBody Faq faq, @PathVariable Long courseId) throws URISyntaxException { log.debug("REST request to save Faq : {}", faq); if (faq.getId() != null) { @@ -82,7 +84,7 @@ public ResponseEntity createFaq(@RequestBody Faq faq, @PathVariable Long if (faq.getCourse() == null || !faq.getCourse().getId().equals(courseId)) { throw new BadRequestAlertException("Course ID in path and FAQ do not match", ENTITY_NAME, "courseIdMismatch"); } - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, faq.getCourse(), null); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.EDITOR, faq.getCourse(), null); Faq savedFaq = faqRepository.save(faq); FaqDTO dto = new FaqDTO(savedFaq); @@ -99,13 +101,14 @@ public ResponseEntity createFaq(@RequestBody Faq faq, @PathVariable Long * if the faq is not valid or if the faq course id does not match with the path variable */ @PutMapping("courses/{courseId}/faqs/{faqId}") - @EnforceAtLeastInstructor + @EnforceAtLeastEditor public ResponseEntity updateFaq(@RequestBody Faq faq, @PathVariable Long faqId, @PathVariable Long courseId) { log.debug("REST request to update Faq : {}", faq); if (faqId == null || !faqId.equals(faq.getId())) { throw new BadRequestAlertException("Id of FAQ and path must match", ENTITY_NAME, "idNull"); } - authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, faq.getCourse(), null); + Course course = courseRepository.findByIdElseThrow(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, null); Faq existingFaq = faqRepository.findByIdElseThrow(faqId); if (!Objects.equals(existingFaq.getCourse().getId(), courseId)) { throw new BadRequestAlertException("Course ID of the FAQ provided courseID must match", ENTITY_NAME, "idNull"); @@ -174,6 +177,34 @@ public ResponseEntity> getFaqForCourse(@PathVariable Long courseId) return ResponseEntity.ok().body(faqDTOS); } + /** + * GET /courses/:courseId/faq-status/:faqState : get all the faqs of a course in the specified status + * + * @param courseId the courseId of the course for which all faqs should be returned + * @param faqState the state of all returned FAQs + * @return the ResponseEntity with status 200 (OK) and the list of faqs in body + */ + @GetMapping("courses/{courseId}/faq-state/{faqState}") + @EnforceAtLeastStudent + public ResponseEntity> getAllFaqForCourseByStatus(@PathVariable Long courseId, @PathVariable String faqState) { + log.debug("REST request to get all Faqs for the course with id : {}", courseId); + FaqState retrivedState = defineState(faqState); + Course course = courseRepository.findByIdElseThrow(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, null); + Set faqs = faqRepository.findAllByCourseIdAndFaqState(courseId, retrivedState); + Set faqDTOS = faqs.stream().map(FaqDTO::new).collect(Collectors.toSet()); + return ResponseEntity.ok().body(faqDTOS); + } + + private FaqState defineState(String faqState) { + return switch (faqState) { + case "ACCEPTED" -> FaqState.ACCEPTED; + case "REJECTED" -> FaqState.REJECTED; + case "PROPOSED" -> FaqState.PROPOSED; + default -> throw new IllegalArgumentException("Unknown state: " + faqState); + }; + } + /** * GET /courses/:courseId/faq-categories : get all the faq categories of a course * diff --git a/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html b/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html index abbfdaf7c010..36645abad973 100644 --- a/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html +++ b/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html @@ -72,7 +72,7 @@ } - @if (course.isAtLeastInstructor && course.faqEnabled) { + @if (course.isAtLeastEditor && course.faqEnabled) { diff --git a/src/main/webapp/app/course/manage/overview/course-management-card.component.html b/src/main/webapp/app/course/manage/overview/course-management-card.component.html index d13dee53b6e5..c590b29a0d75 100644 --- a/src/main/webapp/app/course/manage/overview/course-management-card.component.html +++ b/src/main/webapp/app/course/manage/overview/course-management-card.component.html @@ -339,7 +339,7 @@

}
- - - - + @if (isAtleastInstrucor) { + + + + + } @else { + + + + + }
@@ -64,6 +71,10 @@

+ + + + @@ -83,6 +94,9 @@

+ +

+
@for (category of faq.categories; track category) { @@ -90,27 +104,39 @@

}

-
+ @if (faq.faqState == FaqState.PROPOSED && isAtleastInstrucor) { +
+ + +
+ }
- - + @if (isAtleastInstrucor) { + + }
diff --git a/src/main/webapp/app/faq/faq.component.ts b/src/main/webapp/app/faq/faq.component.ts index 3790932a47a8..a593206a7848 100644 --- a/src/main/webapp/app/faq/faq.component.ts +++ b/src/main/webapp/app/faq/faq.component.ts @@ -1,6 +1,6 @@ import { Component, OnDestroy, OnInit, inject } from '@angular/core'; -import { Faq } from 'app/entities/faq.model'; -import { faEdit, faFilter, faPencilAlt, faPlus, faSort, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { Faq, FaqState } from 'app/entities/faq.model'; +import { faCancel, faCheck, faEdit, faFilter, faPencilAlt, faPlus, faSort, faTrash } from '@fortawesome/free-solid-svg-icons'; import { Subject } from 'rxjs'; import { map } from 'rxjs/operators'; import { AlertService } from 'app/core/util/alert.service'; @@ -15,6 +15,9 @@ import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-catego import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; +import { AccountService } from 'app/core/auth/account.service'; +import { Course } from 'app/entities/course.model'; +import { TranslateService } from '@ngx-translate/core'; @Component({ selector: 'jhi-faq', @@ -24,11 +27,14 @@ import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; imports: [ArtemisSharedModule, CustomExerciseCategoryBadgeComponent, ArtemisSharedComponentModule, ArtemisMarkdownModule], }) export class FaqComponent implements OnInit, OnDestroy { + protected readonly FaqState = FaqState; faqs: Faq[]; + course: Course; filteredFaqs: Faq[]; existingCategories: FaqCategory[]; courseId: number; hasCategories: boolean = false; + isAtleastInstrucor: boolean = false; private dialogErrorSource = new Subject(); dialogError$ = this.dialogErrorSource.asObservable(); @@ -44,11 +50,15 @@ export class FaqComponent implements OnInit, OnDestroy { faPencilAlt = faPencilAlt; faFilter = faFilter; faSort = faSort; + faCancel = faCancel; + faCheck = faCheck; private faqService = inject(FaqService); private route = inject(ActivatedRoute); private alertService = inject(AlertService); private sortService = inject(SortService); + private accountService = inject(AccountService); + private translateService = inject(TranslateService); constructor() { this.predicate = 'id'; @@ -59,6 +69,14 @@ export class FaqComponent implements OnInit, OnDestroy { this.courseId = Number(this.route.snapshot.paramMap.get('courseId')); this.loadAll(); this.loadCourseFaqCategories(this.courseId); + this.route.data.subscribe((data) => { + const course = data['course']; + if (course) { + this.course = course; + this.isAtleastInstrucor = this.accountService.isAtLeastInstructorInCourse(course); + console.log(this.isAtleastInstrucor); + } + }); } ngOnDestroy(): void { @@ -121,4 +139,22 @@ export class FaqComponent implements OnInit, OnDestroy { }); this.applyFilters(); } + + rejectFaq(courseId: number, faq: Faq) { + faq.faqState = FaqState.REJECTED; + faq.course = this.course; + this.faqService.update(courseId, faq).subscribe({ + next: () => this.alertService.success(this.translateService.instant('artemisApp.faq.rejected', { id: faq.id })), + error: (error: HttpErrorResponse) => this.dialogErrorSource.next(error.message), + }); + } + + acceptProposedFaq(courseId: number, faq: Faq) { + faq.faqState = FaqState.ACCEPTED; + faq.course = this.course; + this.faqService.update(courseId, faq).subscribe({ + next: () => this.alertService.success(this.translateService.instant('artemisApp.faq.accepted', { id: faq.id })), + error: (error: HttpErrorResponse) => this.dialogErrorSource.next(error.message), + }); + } } diff --git a/src/main/webapp/app/faq/faq.service.ts b/src/main/webapp/app/faq/faq.service.ts index d0c80cf72e94..c7c9307c6106 100644 --- a/src/main/webapp/app/faq/faq.service.ts +++ b/src/main/webapp/app/faq/faq.service.ts @@ -24,7 +24,13 @@ export class FaqService { ); } + proposeFaq(courseId: number, faq: Faq) { + faq.faqState = FaqState.PROPOSED; + this.create(courseId, faq); + } + update(courseId: number, faq: Faq): Observable { + console.log(faq); const copy = FaqService.convertFaqFromClient(faq); return this.http.put(`${this.resourceUrl}/${courseId}/faqs/${faq.id}`, copy, { observe: 'response' }).pipe( map((res: EntityResponseType) => { @@ -47,6 +53,14 @@ export class FaqService { .pipe(map((res: EntityArrayResponseType) => FaqService.convertFaqCategoryArrayFromServer(res))); } + findAllByCourseIdAndState(courseId: number, faqState: FaqState): Observable { + return this.http + .get(`${this.resourceUrl}/${courseId}/faq-state/${faqState}`, { + observe: 'response', + }) + .pipe(map((res: EntityArrayResponseType) => FaqService.convertFaqCategoryArrayFromServer(res))); + } + delete(courseId: number, faqId: number): Observable> { return this.http.delete(`${this.resourceUrl}/${courseId}/faqs/${faqId}`, { observe: 'response' }); } diff --git a/src/main/webapp/app/overview/course-faq/course-faq.component.ts b/src/main/webapp/app/overview/course-faq/course-faq.component.ts index 51ce0a81b3b2..4adf721aed9e 100644 --- a/src/main/webapp/app/overview/course-faq/course-faq.component.ts +++ b/src/main/webapp/app/overview/course-faq/course-faq.component.ts @@ -8,7 +8,7 @@ import { SidebarData } from 'app/types/sidebar'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { CourseFaqAccordionComponent } from 'app/overview/course-faq/course-faq-accordion-component'; -import { Faq } from 'app/entities/faq.model'; +import { Faq, FaqState } from 'app/entities/faq.model'; import { FaqService } from 'app/faq/faq.service'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { AlertService } from 'app/core/util/alert.service'; @@ -67,7 +67,7 @@ export class CourseFaqComponent implements OnInit, OnDestroy { private loadFaqs() { this.faqService - .findAllByCourseId(this.courseId) + .findAllByCourseIdAndState(this.courseId, FaqState.ACCEPTED) .pipe(map((res: HttpResponse) => res.body)) .subscribe({ next: (res: Faq[]) => { diff --git a/src/main/webapp/i18n/de/faq.json b/src/main/webapp/i18n/de/faq.json index 0cb07d298310..1dd3b4f2ac19 100644 --- a/src/main/webapp/i18n/de/faq.json +++ b/src/main/webapp/i18n/de/faq.json @@ -4,12 +4,17 @@ "home": { "title": "FAQ", "createLabel": "FAQ erstellen", + "proposeLabel": "FAQ vorschlagen", + "accept": "FAQ aktzeptieren", + "reject": "FAQ ablehnen", "filterLabel": "Filter", "createOrEditLabel": "FAQ erstellen oder bearbeiten" }, - "created": "Das FAQ wurde erfolgreich erstellt", - "updated": "Das FAQ wurde erfolgreich aktualisiert", - "deleted": "Das FAQ wurde erfolgreich gelöscht", + "created": "Das FAQ mit der ID {{ id }} wurde erfolgreich erstellt", + "updated": "Das FAQ mit der ID {{ id }} wurde erfolgreich aktualisiert", + "deleted": "Das FAQ mit der ID {{ param }} wurde erfolgreich gelöscht", + "accepted": "Das FAQ mit der ID {{ id }} wurde erfolgreich aktzeptiert", + "rejected": "Das FAQ mit der ID {{ id }} wurde erfolgreich abgelehnt", "delete": { "question": "Soll die FAQ {{ title }} wirklich dauerhaft gelöscht werden? Diese Aktion kann NICHT rückgängig gemacht werden!", "typeNameToConfirm": "Bitte gib den Namen des FAQ zur Bestätigung ein." @@ -18,7 +23,8 @@ "table": { "questionTitle": "Fragentitel", "questionAnswer": "Antwort auf die Frage", - "categories": "Kategorien" + "categories": "Kategorien", + "state": "Status" }, "course": "Kurs" } diff --git a/src/main/webapp/i18n/en/faq.json b/src/main/webapp/i18n/en/faq.json index 1a158eb52c40..c736c2602278 100644 --- a/src/main/webapp/i18n/en/faq.json +++ b/src/main/webapp/i18n/en/faq.json @@ -4,12 +4,17 @@ "home": { "title": "FAQ", "createLabel": "Create a new FAQ", + "proposeLabel": "Propose a new FAQ", + "accept": "Accept FAQ", + "reject": "Reject FAQ", "filterLabel": "Filter", "createOrEditLabel": "Create or edit FAQ" }, - "created": "The FAQ was successfully created", - "updated": "The FAQ was successfully updated", - "deleted": "The FAQ was successfully deleted", + "created": "The FAQ with ID {{ id }} was successfully created", + "updated": "The FAQ with ID {{ id }} was successfully updated", + "deleted": "The FAQ with ID {{ param }} was successfully deleted", + "accepted": "The FAQ with ID {{ id }} was successfully accepted", + "rejected": "The FAQ with ID {{ id }} was successfully rejected", "delete": { "question": "Are you sure you want to permanently delete the FAQ {{ title }}? This action can NOT be undone!", "typeNameToConfirm": "Please type in the name of the FAQ to confirm." @@ -18,7 +23,8 @@ "table": { "questionTitle": "Question title", "questionAnswer": "Question answer", - "categories": "Categories" + "categories": "Categories", + "state": "State" }, "course": "Course" } From 280e3f46fe87a60cd20cb82684f2bb5f231bc89b Mon Sep 17 00:00:00 2001 From: Tim Cremer Date: Wed, 9 Oct 2024 13:23:30 +0200 Subject: [PATCH 02/23] First draft implementation: Added test cases --- src/main/webapp/app/faq/faq.component.ts | 13 ++++-- src/main/webapp/app/faq/faq.service.ts | 7 --- .../communication/FaqIntegrationTest.java | 25 +++++++++- .../spec/component/faq/faq.component.spec.ts | 46 ++++++++++++++++--- .../course-faq/course-faq.component.spec.ts | 8 ++-- .../spec/service/faq.service.spec.ts | 20 ++++++++ 6 files changed, 97 insertions(+), 22 deletions(-) diff --git a/src/main/webapp/app/faq/faq.component.ts b/src/main/webapp/app/faq/faq.component.ts index a593206a7848..3220fd29c6ae 100644 --- a/src/main/webapp/app/faq/faq.component.ts +++ b/src/main/webapp/app/faq/faq.component.ts @@ -74,7 +74,6 @@ export class FaqComponent implements OnInit, OnDestroy { if (course) { this.course = course; this.isAtleastInstrucor = this.accountService.isAtLeastInstructorInCourse(course); - console.log(this.isAtleastInstrucor); } }); } @@ -141,20 +140,28 @@ export class FaqComponent implements OnInit, OnDestroy { } rejectFaq(courseId: number, faq: Faq) { + const previousState = faq.faqState; faq.faqState = FaqState.REJECTED; faq.course = this.course; this.faqService.update(courseId, faq).subscribe({ next: () => this.alertService.success(this.translateService.instant('artemisApp.faq.rejected', { id: faq.id })), - error: (error: HttpErrorResponse) => this.dialogErrorSource.next(error.message), + error: (error: HttpErrorResponse) => { + this.dialogErrorSource.next(error.message); + faq.faqState = previousState; + }, }); } acceptProposedFaq(courseId: number, faq: Faq) { + const previousState = faq.faqState; faq.faqState = FaqState.ACCEPTED; faq.course = this.course; this.faqService.update(courseId, faq).subscribe({ next: () => this.alertService.success(this.translateService.instant('artemisApp.faq.accepted', { id: faq.id })), - error: (error: HttpErrorResponse) => this.dialogErrorSource.next(error.message), + error: (error: HttpErrorResponse) => { + this.dialogErrorSource.next(error.message); + faq.faqState = previousState; + }, }); } } diff --git a/src/main/webapp/app/faq/faq.service.ts b/src/main/webapp/app/faq/faq.service.ts index c7c9307c6106..48e16923e7d4 100644 --- a/src/main/webapp/app/faq/faq.service.ts +++ b/src/main/webapp/app/faq/faq.service.ts @@ -16,7 +16,6 @@ export class FaqService { create(courseId: number, faq: Faq): Observable { const copy = FaqService.convertFaqFromClient(faq); - copy.faqState = FaqState.ACCEPTED; return this.http.post(`${this.resourceUrl}/${courseId}/faqs`, copy, { observe: 'response' }).pipe( map((res: EntityResponseType) => { return res; @@ -24,13 +23,7 @@ export class FaqService { ); } - proposeFaq(courseId: number, faq: Faq) { - faq.faqState = FaqState.PROPOSED; - this.create(courseId, faq); - } - update(courseId: number, faq: Faq): Observable { - console.log(faq); const copy = FaqService.convertFaqFromClient(faq); return this.http.put(`${this.resourceUrl}/${courseId}/faqs/${faq.id}`, copy, { observe: 'response' }).pipe( map((res: EntityResponseType) => { diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/FaqIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/FaqIntegrationTest.java index 5f226f52d4dc..a89c0ee8b3b8 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/FaqIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/FaqIntegrationTest.java @@ -15,6 +15,7 @@ import de.tum.cit.aet.artemis.communication.domain.Faq; import de.tum.cit.aet.artemis.communication.domain.FaqState; +import de.tum.cit.aet.artemis.communication.dto.FaqDTO; import de.tum.cit.aet.artemis.communication.repository.FaqRepository; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; @@ -31,13 +32,16 @@ class FaqIntegrationTest extends AbstractSpringIntegrationIndependentTest { private Faq faq; + private FaqDTO faqDTO; + @BeforeEach void initTestCase() throws Exception { int numberOfTutors = 2; userUtilService.addUsers(TEST_PREFIX, 1, numberOfTutors, 0, 1); List courses = courseUtilService.createCoursesWithExercisesAndLectures(TEST_PREFIX, true, true, numberOfTutors); this.course1 = this.courseRepository.findByIdWithExercisesAndExerciseDetailsAndLecturesElseThrow(courses.getFirst().getId()); - this.faq = FaqFactory.generateFaq(course1, FaqState.ACCEPTED, "answer", "title"); + this.faq = FaqFactory.generateFaq(course1, FaqState.PROPOSED, "answer", "title"); + this.faqDTO = new FaqDTO(faq); faqRepository.save(this.faq); // Add users that are not in the course userUtilService.createAndSaveUser(TEST_PREFIX + "student42"); @@ -116,7 +120,8 @@ void updateFaq_IdsDoNotMatch_shouldNotUpdateFaq() throws Exception { Faq faq = faqRepository.findById(this.faq.getId()).orElseThrow(); faq.setQuestionTitle("Updated"); faq.setFaqState(FaqState.PROPOSED); - Faq updatedFaq = request.putWithResponseBody("/api/courses/" + faq.getCourse().getId() + 1 + "/faqs/" + faq.getId(), faq, Faq.class, HttpStatus.BAD_REQUEST); + faq.setId(faq.getId() + 1); + Faq updatedFaq = request.putWithResponseBody("/api/courses/" + course1.getId() + "/faqs/" + (faq.getId() - 1), faq, Faq.class, HttpStatus.BAD_REQUEST); } @Test @@ -161,4 +166,20 @@ void deleteFaq_IdsDoNotMatch_shouldNotDeleteFAQ() throws Exception { assertThat(faqOptional).isPresent(); } + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testgetFaqByCourseId() throws Exception { + Set faqs = faqRepository.findAllByCourseIdAndFaqState(this.course1.getId(), FaqState.PROPOSED); + Set returnedFaqs = request.get("/api/courses/" + course1.getId() + "/faqs", HttpStatus.OK, Set.class); + assertThat(returnedFaqs).hasSize(faqs.size()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testgetFaqByCourseIdAndState() throws Exception { + Set faqs = faqRepository.findAllByCourseIdAndFaqState(this.course1.getId(), FaqState.PROPOSED); + Set returnedFaqs = request.get("/api/courses/" + course1.getId() + "/faq-state/" + "PROPOSED", HttpStatus.OK, Set.class); + assertThat(returnedFaqs).hasSize(faqs.size()); + } + } diff --git a/src/test/javascript/spec/component/faq/faq.component.spec.ts b/src/test/javascript/spec/component/faq/faq.component.spec.ts index d31f60b8e2f2..134f70bc1f7b 100644 --- a/src/test/javascript/spec/component/faq/faq.component.spec.ts +++ b/src/test/javascript/spec/component/faq/faq.component.spec.ts @@ -9,7 +9,7 @@ import { MockRouter } from '../../helpers/mocks/mock-router'; import { MockTranslateService } from '../../helpers/mocks/service/mock-translate.service'; import { ArtemisTestModule } from '../../test.module'; import { FaqService } from 'app/faq/faq.service'; -import { Faq } from 'app/entities/faq.model'; +import { Faq, FaqState } from 'app/entities/faq.model'; import { ArtemisMarkdownEditorModule } from 'app/shared/markdown-editor/markdown-editor.module'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; @@ -18,6 +18,8 @@ import { FaqCategory } from 'app/entities/faq-category.model'; import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component'; import { AlertService } from 'app/core/util/alert.service'; import { SortService } from 'app/shared/service/sort.service'; +import { MockAccountService } from '../../helpers/mocks/service/mock-account.service'; +import { AccountService } from 'app/core/auth/account.service'; function createFaq(id: number, category: string, color: string): Faq { const faq = new Faq(); @@ -25,6 +27,7 @@ function createFaq(id: number, category: string, color: string): Faq { faq.questionTitle = 'questionTitle'; faq.questionAnswer = 'questionAnswer'; faq.categories = [new FaqCategory(category, color)]; + faq.faqState = FaqState.PROPOSED; return faq; } @@ -57,12 +60,11 @@ describe('FaqComponent', () => { providers: [ { provide: TranslateService, useClass: MockTranslateService }, { provide: Router, useClass: MockRouter }, + { provide: AccountService, useClass: MockAccountService }, { provide: ActivatedRoute, useValue: { - parent: { - data: of({ course: { id: 1 } }), - }, + data: of({ course: { id: 1 } }), snapshot: { paramMap: convertToParamMap({ courseId: '1', @@ -169,9 +171,41 @@ describe('FaqComponent', () => { it('should call sortService when sortRows is called', () => { jest.spyOn(sortService, 'sortByProperty').mockReturnValue([]); - faqComponent.sortRows(); - expect(sortService.sortByProperty).toHaveBeenCalledOnce(); }); + + it('should reject faq properly', () => { + jest.spyOn(faqService, 'update').mockReturnValue(of(new HttpResponse({ body: faq1 }))); + faqComponentFixture.detectChanges(); + faqComponent.rejectFaq(courseId, faq1); + expect(faqService.update).toHaveBeenCalledExactlyOnceWith(courseId, faq1); + expect(faq1.faqState).toEqual(FaqState.REJECTED); + }); + + it('should not change status if rejection fails', () => { + const error = { status: 500 }; + jest.spyOn(faqService, 'update').mockReturnValue(throwError(() => new HttpErrorResponse(error))); + faqComponentFixture.detectChanges(); + faqComponent.rejectFaq(courseId, faq1); + expect(faqService.update).toHaveBeenCalledExactlyOnceWith(courseId, faq1); + expect(faq1.faqState).toEqual(FaqState.PROPOSED); + }); + + it('should accepts proposed faq properly', () => { + jest.spyOn(faqService, 'update').mockReturnValue(of(new HttpResponse({ body: faq1 }))); + faqComponentFixture.detectChanges(); + faqComponent.acceptProposedFaq(courseId, faq1); + expect(faqService.update).toHaveBeenCalledExactlyOnceWith(courseId, faq1); + expect(faq1.faqState).toEqual(FaqState.ACCEPTED); + }); + + it('should not change status if acceptance fails', () => { + const error = { status: 500 }; + jest.spyOn(faqService, 'update').mockReturnValue(throwError(() => new HttpErrorResponse(error))); + faqComponentFixture.detectChanges(); + faqComponent.acceptProposedFaq(courseId, faq1); + expect(faqService.update).toHaveBeenCalledExactlyOnceWith(courseId, faq1); + expect(faq1.faqState).toEqual(FaqState.PROPOSED); + }); }); diff --git a/src/test/javascript/spec/component/overview/course-faq/course-faq.component.spec.ts b/src/test/javascript/spec/component/overview/course-faq/course-faq.component.spec.ts index 6c5a21b56ddb..b5dc43636ede 100644 --- a/src/test/javascript/spec/component/overview/course-faq/course-faq.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-faq/course-faq.component.spec.ts @@ -16,7 +16,7 @@ import { ArtemisSharedComponentModule } from 'app/shared/components/shared-compo import { ArtemisSharedModule } from 'app/shared/shared.module'; import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component'; import { CourseFaqAccordionComponent } from 'app/overview/course-faq/course-faq-accordion-component'; -import { Faq } from 'app/entities/faq.model'; +import { Faq, FaqState } from 'app/entities/faq.model'; import { FaqCategory } from 'app/entities/faq-category.model'; function createFaq(id: number, category: string, color: string): Faq { @@ -62,7 +62,7 @@ describe('CourseFaqs', () => { }, }, MockProvider(FaqService, { - findAllByCourseId: () => { + findAllByCourseIdAndState: () => { return of( new HttpResponse({ body: [faq1, faq2, faq3], @@ -108,10 +108,10 @@ describe('CourseFaqs', () => { }); it('should fetch faqs when initialized', () => { - const findAllSpy = jest.spyOn(faqService, 'findAllByCourseId'); + const findAllSpy = jest.spyOn(faqService, 'findAllByCourseIdAndState'); courseFaqComponentFixture.detectChanges(); - expect(findAllSpy).toHaveBeenCalledExactlyOnceWith(1); + expect(findAllSpy).toHaveBeenCalledExactlyOnceWith(1, FaqState.ACCEPTED); expect(courseFaqComponent.faqs).toHaveLength(3); }); diff --git a/src/test/javascript/spec/service/faq.service.spec.ts b/src/test/javascript/spec/service/faq.service.spec.ts index b5612f991ebd..8074b3047baa 100644 --- a/src/test/javascript/spec/service/faq.service.spec.ts +++ b/src/test/javascript/spec/service/faq.service.spec.ts @@ -132,6 +132,26 @@ describe('Faq Service', () => { expect(expectedResult.body).toEqual(expected); }); + it('should find faqs by courseId and status', () => { + const category = { + color: '#6ae8ac', + category: 'category1', + } as FaqCategory; + const returnedFromService = [{ ...elemDefault, categories: [JSON.stringify(category)] }]; + const expected = [{ ...elemDefault, categories: [new FaqCategory('category1', '#6ae8ac')] }]; + const courseId = 1; + service + .findAllByCourseIdAndState(courseId, FaqState.ACCEPTED) + .pipe(take(1)) + .subscribe((resp) => (expectedResult = resp)); + const req = httpMock.expectOne({ + url: `api/courses/${courseId}/faq-state/${FaqState.ACCEPTED}`, + method: 'GET', + }); + req.flush(returnedFromService); + expect(expectedResult.body).toEqual(expected); + }); + it('should find all categories by courseId', () => { const category = { color: '#6ae8ac', From 2eb7316e66e09cb08bfd2036f1d9becd1ca36256 Mon Sep 17 00:00:00 2001 From: Tim Cremer Date: Wed, 9 Oct 2024 16:02:57 +0200 Subject: [PATCH 03/23] fixed tests --- .../spec/component/faq/faq-update.component.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/javascript/spec/component/faq/faq-update.component.spec.ts b/src/test/javascript/spec/component/faq/faq-update.component.spec.ts index 04c04b3d12d3..b0736061a8c9 100644 --- a/src/test/javascript/spec/component/faq/faq-update.component.spec.ts +++ b/src/test/javascript/spec/component/faq/faq-update.component.spec.ts @@ -11,7 +11,7 @@ import { MockTranslateService } from '../../helpers/mocks/service/mock-translate import { ArtemisTestModule } from '../../test.module'; import { FaqUpdateComponent } from 'app/faq/faq-update.component'; import { FaqService } from 'app/faq/faq.service'; -import { Faq, FaqState } from 'app/entities/faq.model'; +import { Faq } from 'app/entities/faq.model'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { AlertService } from 'app/core/util/alert.service'; import { FaqCategory } from 'app/entities/faq-category.model'; @@ -111,7 +111,7 @@ describe('FaqUpdateComponent', () => { faqUpdateComponent.save(); tick(); - expect(createSpy).toHaveBeenCalledExactlyOnceWith(courseId, { faqState: FaqState.ACCEPTED, questionTitle: 'test1' }); + expect(createSpy).toHaveBeenCalledExactlyOnceWith(courseId, { questionTitle: 'test1', faqState: 'PROPOSED' }); expect(faqUpdateComponent.isSaving).toBeFalse(); })); @@ -140,7 +140,7 @@ describe('FaqUpdateComponent', () => { tick(); faqUpdateComponentFixture.detectChanges(); - expect(updateSpy).toHaveBeenCalledExactlyOnceWith(courseId, { id: 6, questionTitle: 'test1Updated' }); + expect(updateSpy).toHaveBeenCalledExactlyOnceWith(courseId, { id: 6, questionTitle: 'test1Updated', faqState: 'PROPOSED' }); })); it('should navigate to previous state', fakeAsync(() => { From 0ab7a919eb4cc5e62938aa7ee22c7284174f2899 Mon Sep 17 00:00:00 2001 From: Tim Cremer Date: Sat, 12 Oct 2024 13:47:36 +0200 Subject: [PATCH 04/23] Added proper alert messages --- .../webapp/app/faq/faq-update.component.ts | 18 +++++-- src/main/webapp/i18n/de/faq.json | 6 ++- src/main/webapp/i18n/en/faq.json | 2 + .../faq/faq-update.component.spec.ts | 54 ++++++++++++++++++- 4 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/main/webapp/app/faq/faq-update.component.ts b/src/main/webapp/app/faq/faq-update.component.ts index ca16f63e8413..5abd6ac1dd7f 100644 --- a/src/main/webapp/app/faq/faq-update.component.ts +++ b/src/main/webapp/app/faq/faq-update.component.ts @@ -32,7 +32,7 @@ export class FaqUpdateComponent implements OnInit { existingCategories: FaqCategory[]; faqCategories: FaqCategory[]; courseId: number; - isAtleastInstructor: boolean = false; + isAtLeastInstructor: boolean = false; domainActionsDescription = [new FormulaAction()]; // Icons @@ -59,7 +59,7 @@ export class FaqUpdateComponent implements OnInit { if (course) { this.faq.course = course; this.loadCourseFaqCategories(course.id); - this.isAtleastInstructor = this.accountService.isAtLeastInstructorInCourse(course); + this.isAtLeastInstructor = this.accountService.isAtLeastInstructorInCourse(course); } this.faqCategories = faq?.categories ? faq.categories : []; }); @@ -81,7 +81,7 @@ export class FaqUpdateComponent implements OnInit { */ save() { this.isSaving = true; - this.faq.faqState = this.isAtleastInstructor ? FaqState.ACCEPTED : FaqState.PROPOSED; + this.faq.faqState = this.isAtLeastInstructor ? FaqState.ACCEPTED : FaqState.PROPOSED; if (this.faq.id !== undefined) { this.subscribeToSaveResponse(this.faqService.update(this.courseId, this.faq)); } else { @@ -111,13 +111,21 @@ export class FaqUpdateComponent implements OnInit { if (faqBody) { this.faq = faqBody; } - this.alertService.success(this.translateService.instant('artemisApp.faq.created', { id: faq.id })); + if (this.isAtLeastInstructor) { + this.alertService.success(this.translateService.instant('artemisApp.faq.created', { id: faq.id })); + } else { + this.alertService.success(this.translateService.instant('artemisApp.faq.proposed', { id: faq.id })); + } this.router.navigate(['course-management', this.courseId, 'faqs']); }, }); } else { this.isSaving = false; - this.alertService.success(this.translateService.instant('artemisApp.faq.updated', { id: faq.id })); + if (this.isAtLeastInstructor) { + this.alertService.success(this.translateService.instant('artemisApp.faq.updated', { id: faq.id })); + } else { + this.alertService.success(this.translateService.instant('artemisApp.faq.proposedChange', { id: faq.id })); + } this.router.navigate(['course-management', this.courseId, 'faqs']); } } diff --git a/src/main/webapp/i18n/de/faq.json b/src/main/webapp/i18n/de/faq.json index 1dd3b4f2ac19..413239c161e1 100644 --- a/src/main/webapp/i18n/de/faq.json +++ b/src/main/webapp/i18n/de/faq.json @@ -5,15 +5,17 @@ "title": "FAQ", "createLabel": "FAQ erstellen", "proposeLabel": "FAQ vorschlagen", - "accept": "FAQ aktzeptieren", + "accept": "FAQ akzeptieren", "reject": "FAQ ablehnen", "filterLabel": "Filter", "createOrEditLabel": "FAQ erstellen oder bearbeiten" }, "created": "Das FAQ mit der ID {{ id }} wurde erfolgreich erstellt", "updated": "Das FAQ mit der ID {{ id }} wurde erfolgreich aktualisiert", + "proposed": "Das FAQ mit der ID {{ id }} wurde erfolgreich vorgeschlagen", + "proposedChange": "Die Änderungen am FAQ mit der ID {{ id }} wurde erfolgreich vorgeschlagen", "deleted": "Das FAQ mit der ID {{ param }} wurde erfolgreich gelöscht", - "accepted": "Das FAQ mit der ID {{ id }} wurde erfolgreich aktzeptiert", + "accepted": "Das FAQ mit der ID {{ id }} wurde erfolgreich akzeptieren", "rejected": "Das FAQ mit der ID {{ id }} wurde erfolgreich abgelehnt", "delete": { "question": "Soll die FAQ {{ title }} wirklich dauerhaft gelöscht werden? Diese Aktion kann NICHT rückgängig gemacht werden!", diff --git a/src/main/webapp/i18n/en/faq.json b/src/main/webapp/i18n/en/faq.json index c736c2602278..8aeac9a29c8e 100644 --- a/src/main/webapp/i18n/en/faq.json +++ b/src/main/webapp/i18n/en/faq.json @@ -12,6 +12,8 @@ }, "created": "The FAQ with ID {{ id }} was successfully created", "updated": "The FAQ with ID {{ id }} was successfully updated", + "proposed": "The FAQ with ID {{ id }} was successfully proposed", + "proposedChange": "The changes to the FAQ with the ID {{ id }} have been successfully proposed", "deleted": "The FAQ with ID {{ param }} was successfully deleted", "accepted": "The FAQ with ID {{ id }} was successfully accepted", "rejected": "The FAQ with ID {{ id }} was successfully rejected", diff --git a/src/test/javascript/spec/component/faq/faq-update.component.spec.ts b/src/test/javascript/spec/component/faq/faq-update.component.spec.ts index b0736061a8c9..275bd4cb4f48 100644 --- a/src/test/javascript/spec/component/faq/faq-update.component.spec.ts +++ b/src/test/javascript/spec/component/faq/faq-update.component.spec.ts @@ -93,6 +93,32 @@ describe('FaqUpdateComponent', () => { it('should create faq', fakeAsync(() => { faqUpdateComponent.faq = { questionTitle: 'test1' } as Faq; + faqUpdateComponent.isAtLeastInstructor = true; + const createSpy = jest.spyOn(faqService, 'create').mockReturnValue( + of( + new HttpResponse({ + body: { + id: 3, + questionTitle: 'test1', + course: { + id: 1, + }, + } as Faq, + }), + ), + ); + + faqUpdateComponentFixture.detectChanges(); + faqUpdateComponent.save(); + tick(); + + expect(createSpy).toHaveBeenCalledExactlyOnceWith(courseId, { questionTitle: 'test1', faqState: 'ACCEPTED' }); + expect(faqUpdateComponent.isSaving).toBeFalse(); + })); + + it('should propose faq', fakeAsync(() => { + faqUpdateComponent.faq = { questionTitle: 'test1' } as Faq; + faqUpdateComponent.isAtLeastInstructor = false; const createSpy = jest.spyOn(faqService, 'create').mockReturnValue( of( new HttpResponse({ @@ -117,7 +143,7 @@ describe('FaqUpdateComponent', () => { it('should edit a faq', fakeAsync(() => { activatedRoute.parent!.data = of({ course: { id: 1 }, faq: { id: 6 } }); - + faqUpdateComponent.isAtLeastInstructor = true; faqUpdateComponentFixture.detectChanges(); faqUpdateComponent.faq = { id: 6, questionTitle: 'test1Updated' } as Faq; @@ -139,7 +165,33 @@ describe('FaqUpdateComponent', () => { faqUpdateComponent.save(); tick(); faqUpdateComponentFixture.detectChanges(); + expect(updateSpy).toHaveBeenCalledExactlyOnceWith(courseId, { id: 6, questionTitle: 'test1Updated', faqState: 'ACCEPTED' }); + })); + + it('should propose to edit a faq', fakeAsync(() => { + activatedRoute.parent!.data = of({ course: { id: 1 }, faq: { id: 6 } }); + faqUpdateComponent.isAtLeastInstructor = false; + faqUpdateComponentFixture.detectChanges(); + faqUpdateComponent.faq = { id: 6, questionTitle: 'test1Updated' } as Faq; + const updateSpy = jest.spyOn(faqService, 'update').mockReturnValue( + of>( + new HttpResponse({ + body: { + id: 6, + questionTitle: 'test1Updated', + questionAnswer: 'answer', + course: { + id: 1, + }, + } as Faq, + }), + ), + ); + + faqUpdateComponent.save(); + tick(); + faqUpdateComponentFixture.detectChanges(); expect(updateSpy).toHaveBeenCalledExactlyOnceWith(courseId, { id: 6, questionTitle: 'test1Updated', faqState: 'PROPOSED' }); })); From edf208eb89db4e56e00702a64f734773179af438 Mon Sep 17 00:00:00 2001 From: Simon Entholzer <33342534+SimonEntholzer@users.noreply.github.com> Date: Sat, 12 Oct 2024 16:25:46 +0200 Subject: [PATCH 05/23] `Integrated code lifecycle`: Simplify user interface for ssh keys (#9 von 454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kaan Çaylı <38523756+kaancayli@users.noreply.github.com> --- .../tum/cit/aet/artemis/core/domain/User.java | 5 + .../tum/cit/aet/artemis/core/dto/UserDTO.java | 10 + .../core/web/open/PublicAccountResource.java | 1 + .../domain/AuthenticationMechanism.java | 2 +- src/main/webapp/app/core/user/user.model.ts | 1 + .../documentation-button.component.ts | 2 +- .../documentation-link.component.html | 5 + .../documentation-link.component.ts | 25 +++ .../ssh-user-settings.component.html | 197 ++++++++++++------ .../ssh-user-settings.component.scss | 134 ++++++++++++ .../ssh-user-settings.component.ts | 48 ++++- .../user-settings/user-settings.module.ts | 3 +- .../shared/user-settings/user-settings.scss | 5 - .../app/shared/util/os-detector.util.ts | 17 ++ .../content/scss/themes/_dark-variables.scss | 7 + .../scss/themes/_default-variables.scss | 7 + src/main/webapp/i18n/de/userSettings.json | 19 +- src/main/webapp/i18n/en/userSettings.json | 17 +- .../ssh-user-settings.component.spec.ts | 48 ++++- 19 files changed, 463 insertions(+), 90 deletions(-) create mode 100644 src/main/webapp/app/shared/components/documentation-link/documentation-link.component.html create mode 100644 src/main/webapp/app/shared/components/documentation-link/documentation-link.component.ts create mode 100644 src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.scss create mode 100644 src/main/webapp/app/shared/util/os-detector.util.ts diff --git a/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java b/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java index 7fa995658af1..6498340f3bc2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java @@ -561,4 +561,9 @@ public void hasAcceptedIrisElseThrow() { public String getSshPublicKey() { return sshPublicKey; } + + @Nullable + public @Size(max = 100) String getSshPublicKeyHash() { + return sshPublicKeyHash; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java index 1ac72940f919..3d627425a8f7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java @@ -78,6 +78,8 @@ public class UserDTO extends AuditingEntityDTO { private String sshPublicKey; + private String sshKeyHash; + private ZonedDateTime irisAccepted; public UserDTO() { @@ -291,4 +293,12 @@ public ZonedDateTime getIrisAccepted() { public void setIrisAccepted(ZonedDateTime irisAccepted) { this.irisAccepted = irisAccepted; } + + public String getSshKeyHash() { + return sshKeyHash; + } + + public void setSshKeyHash(String sshKeyHash) { + this.sshKeyHash = sshKeyHash; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java index 992b340359a1..bd673ece51d4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java @@ -167,6 +167,7 @@ public ResponseEntity getAccount() { userDTO.setVcsAccessToken(user.getVcsAccessToken()); userDTO.setVcsAccessTokenExpiryDate(user.getVcsAccessTokenExpiryDate()); userDTO.setSshPublicKey(user.getSshPublicKey()); + userDTO.setSshKeyHash(user.getSshPublicKeyHash()); log.info("GET /account {} took {}ms", user.getLogin(), System.currentTimeMillis() - start); return ResponseEntity.ok(userDTO); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java index 3caec801ed53..239ef3674d44 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java @@ -20,5 +20,5 @@ public enum AuthenticationMechanism { /** * The user used the artemis client code editor to authenticate to the LocalVC */ - CODE_EDITOR + CODE_EDITOR, } diff --git a/src/main/webapp/app/core/user/user.model.ts b/src/main/webapp/app/core/user/user.model.ts index b55ff839fd54..f793581b21a3 100644 --- a/src/main/webapp/app/core/user/user.model.ts +++ b/src/main/webapp/app/core/user/user.model.ts @@ -16,6 +16,7 @@ export class User extends Account { public vcsAccessToken?: string; public vcsAccessTokenExpiryDate?: string; public sshPublicKey?: string; + public sshKeyHash?: string; public irisAccepted?: dayjs.Dayjs; constructor( diff --git a/src/main/webapp/app/shared/components/documentation-button/documentation-button.component.ts b/src/main/webapp/app/shared/components/documentation-button/documentation-button.component.ts index f598865bb6a6..7724361d798e 100644 --- a/src/main/webapp/app/shared/components/documentation-button/documentation-button.component.ts +++ b/src/main/webapp/app/shared/components/documentation-button/documentation-button.component.ts @@ -12,7 +12,7 @@ const DocumentationLinks = { Quiz: 'exercises/quiz/', Model: 'exercises/modeling/', Programming: 'exercises/programming/', - SshSetup: 'exercises/programming.html#repository-access', + SshSetup: 'icl/ssh-intro', Text: 'exercises/textual/', FileUpload: 'exercises/file-upload/', Notifications: 'notifications/', diff --git a/src/main/webapp/app/shared/components/documentation-link/documentation-link.component.html b/src/main/webapp/app/shared/components/documentation-link/documentation-link.component.html new file mode 100644 index 000000000000..b3e990001b2f --- /dev/null +++ b/src/main/webapp/app/shared/components/documentation-link/documentation-link.component.html @@ -0,0 +1,5 @@ +@if (displayString() && documentationType()) { + + + +} diff --git a/src/main/webapp/app/shared/components/documentation-link/documentation-link.component.ts b/src/main/webapp/app/shared/components/documentation-link/documentation-link.component.ts new file mode 100644 index 000000000000..86f5d19aafb6 --- /dev/null +++ b/src/main/webapp/app/shared/components/documentation-link/documentation-link.component.ts @@ -0,0 +1,25 @@ +import { Component, input } from '@angular/core'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; + +// The routes here are used to build the link to the documentation. +// Therefore, it's important that they exactly match the url to the subpage of the documentation. +// Additionally, the case names must match the keys in documentationLinks.json for the tooltip. +const DocumentationLinks: { [key: string]: string } = { + SshSetup: 'icl/ssh-intro', +}; + +export type DocumentationType = keyof typeof DocumentationLinks; + +@Component({ + selector: 'jhi-documentation-link', + standalone: true, + templateUrl: './documentation-link.component.html', + imports: [TranslateDirective], +}) +export class DocumentationLinkComponent { + readonly BASE_URL = 'https://docs.artemis.cit.tum.de/user/'; + readonly DocumentationLinks = DocumentationLinks; + + documentationType = input(); + displayString = input(); +} diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html index 8899797a2e8c..7372a07ac241 100644 --- a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html @@ -3,80 +3,153 @@

@if (currentUser) {
-
-
- - -
-
- @if (storedSshKey === '' && !editSshKey) { -
- -
- } - @if (storedSshKey !== '' && !editSshKey) { -
-
- {{ sshKey }} -
-
-
+ + @if (keyCount === 0 && !showSshKey) { +
+
+

+
+

+ + + +

+
+ +
-
- -
} - @if (editSshKey) { -
-
+ + + @if (keyCount > 0 && !showSshKey) { +
+

+ +
+

+ + + +

+
+ + + + + + + + + + + + + + + +
+
+
+ {{ sshKeyHash }} +
+
+
+ +
+
+ } + + + @if (showSshKey) {
+ @if (isKeyReadonly) { +

+ } @else { +

+ } + +
+

+ + + +

+
+
- +

+
-
-
- + +
+

+ + {{ copyInstructions }} +

+
+ + @if (!isKeyReadonly) { +
+
+ +
+
+ +
-
- + } @else { +
+
+ +
-
+ }
}
diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.scss b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.scss new file mode 100644 index 000000000000..8255e6ade0f4 --- /dev/null +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.scss @@ -0,0 +1,134 @@ +textarea { + width: 600px; + height: 150px; + font-size: small; + font-family: + Bitstream Vera Sans Mono, + Courier New, + monospace; +} + +table { + width: 100%; + border-collapse: collapse; + margin: 20px 0; + font-size: 16px; + text-align: left; +} + +th, +td { + padding: 15px 15px; +} + +/* Button styling */ + +.btn { + border-radius: 0; +} + +.container { + position: relative; +} + +.narrower-box { + max-width: 500px; + margin: 0 auto; +} + +.font-medium { + font-size: medium; +} + +.icon-column { + width: auto; + margin-right: 15px; +} + +.vertical-center { + margin: 0; + position: absolute; + top: 50%; + left: 50%; + -ms-transform: translateY(-50%); + transform: translateY(-50%); +} + +tbody tr:hover { + background-color: var(--ssh-key-table-hover-background); +} + +/* dd container */ +.dropdown { + display: inline-block; + position: relative; + outline: none; + margin: 0; +} + +/* button */ +.dropbtn { + padding: 0; + color: grey; + cursor: pointer; + transition: 0.35s ease-out; +} + +/* dd content */ +.dropdown .dropdown-content { + position: absolute; + top: 50%; + min-width: 120%; + box-shadow: 0 8px 16px var(--ssh-key-settings-shadow); + z-index: 100000; // makes sure the drop down menu is always shown at the highest view plane + visibility: hidden; + opacity: 1; + transition: 0.35s ease-out; + width: 80px; +} + +/* show dd content */ +.dropdown:focus .dropdown-content { + outline: none; + transform: translateY(20px); + visibility: visible; + opacity: 1; + background-color: var(--light); +} + +/* mask to close menu by clicking on the button */ +.dropdown .db2 { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + opacity: 0; + cursor: pointer; + z-index: 10; + display: none; +} + +.dropdown:focus .db2 { + display: inline-block; +} + +.dropdown .db2:focus .dropdown-content { + outline: none; + visibility: hidden; + margin-right: 15px; + opacity: 0; +} + +.dropdown-button { + margin: auto; + color: var(--ssh-key-settings-text-color); + background-color: var(--dropdown-bg); + border-color: var(--dropdown-bg); + width: 80px; +} + +.dropdown-button:hover { + background-color: var(--ssh-key-settings-dropdown-buttons-hover); + border-color: var(--ssh-key-settings-dropdown-buttons-hover); +} diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.ts b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.ts index 808fe99b016c..6fa6fbc9336e 100644 --- a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.ts +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.ts @@ -4,27 +4,34 @@ import { AccountService } from 'app/core/auth/account.service'; import { Subject, Subscription, tap } from 'rxjs'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { PROFILE_LOCALVC } from 'app/app.constants'; -import { faEdit, faSave, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { faEdit, faEllipsis, faSave, faTrash } from '@fortawesome/free-solid-svg-icons'; import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; import { ButtonSize, ButtonType } from 'app/shared/components/button.component'; import { AlertService } from 'app/core/util/alert.service'; +import { getOS } from 'app/shared/util/os-detector.util'; @Component({ selector: 'jhi-account-information', templateUrl: './ssh-user-settings.component.html', - styleUrls: ['../user-settings.scss'], + styleUrls: ['../user-settings.scss', './ssh-user-settings.component.scss'], }) export class SshUserSettingsComponent implements OnInit { readonly documentationType: DocumentationType = 'SshSetup'; currentUser?: User; localVCEnabled = false; sshKey = ''; + sshKeyHash = ''; storedSshKey = ''; - editSshKey = false; + showSshKey = false; + keyCount = 0; + isKeyReadonly = true; + copyInstructions = ''; readonly faEdit = faEdit; readonly faSave = faSave; readonly faTrash = faTrash; + readonly faEllipsis = faEllipsis; + private authStateSubscription: Subscription; private dialogErrorSource = new Subject(); @@ -40,14 +47,18 @@ export class SshUserSettingsComponent implements OnInit { this.profileService.getProfileInfo().subscribe((profileInfo) => { this.localVCEnabled = profileInfo.activeProfiles.includes(PROFILE_LOCALVC); }); - + this.setMessageBasedOnOS(getOS()); this.authStateSubscription = this.accountService .getAuthenticationState() .pipe( tap((user: User) => { this.storedSshKey = user.sshPublicKey || ''; this.sshKey = this.storedSshKey; + this.sshKeyHash = user.sshKeyHash || ''; this.currentUser = user; + // currently only 0 or 1 key are supported + this.keyCount = this.sshKey ? 1 : 0; + this.isKeyReadonly = !!this.sshKey; return this.currentUser; }), ) @@ -58,8 +69,10 @@ export class SshUserSettingsComponent implements OnInit { this.accountService.addSshPublicKey(this.sshKey).subscribe({ next: () => { this.alertService.success('artemisApp.userSettings.sshSettingsPage.saveSuccess'); - this.editSshKey = false; + this.showSshKey = false; this.storedSshKey = this.sshKey; + this.keyCount = this.keyCount + 1; + this.isKeyReadonly = true; }, error: () => { this.alertService.error('artemisApp.userSettings.sshSettingsPage.saveFailure'); @@ -68,12 +81,14 @@ export class SshUserSettingsComponent implements OnInit { } deleteSshKey() { - this.editSshKey = false; + this.showSshKey = false; this.accountService.deleteSshPublicKey().subscribe({ next: () => { this.alertService.success('artemisApp.userSettings.sshSettingsPage.deleteSuccess'); this.sshKey = ''; this.storedSshKey = ''; + this.keyCount = this.keyCount - 1; + this.isKeyReadonly = false; }, error: () => { this.alertService.error('artemisApp.userSettings.sshSettingsPage.deleteFailure'); @@ -83,10 +98,29 @@ export class SshUserSettingsComponent implements OnInit { } cancelEditingSshKey() { - this.editSshKey = !this.editSshKey; + this.showSshKey = !this.showSshKey; this.sshKey = this.storedSshKey; } protected readonly ButtonType = ButtonType; protected readonly ButtonSize = ButtonSize; + + private setMessageBasedOnOS(os: string): void { + switch (os) { + case 'Windows': + this.copyInstructions = 'cat ~/.ssh/id_ed25519.pub | clip'; + break; + case 'MacOS': + this.copyInstructions = 'pbcopy < ~/.ssh/id_ed25519.pub'; + break; + case 'Linux': + this.copyInstructions = 'xclip -selection clipboard < ~/.ssh/id_ed25519.pub'; + break; + case 'Android': + this.copyInstructions = 'termux-clipboard-set < ~/.ssh/id_ed25519.pub'; + break; + default: + this.copyInstructions = 'Ctrl + C'; + } + } } diff --git a/src/main/webapp/app/shared/user-settings/user-settings.module.ts b/src/main/webapp/app/shared/user-settings/user-settings.module.ts index bc91e586ecb2..fe912bdef56a 100644 --- a/src/main/webapp/app/shared/user-settings/user-settings.module.ts +++ b/src/main/webapp/app/shared/user-settings/user-settings.module.ts @@ -12,9 +12,10 @@ import { VcsAccessTokensSettingsComponent } from 'app/shared/user-settings/vcs-a import { ClipboardModule } from '@angular/cdk/clipboard'; import { FormDateTimePickerModule } from 'app/shared/date-time-picker/date-time-picker.module'; import { IdeSettingsComponent } from 'app/shared/user-settings/ide-preferences/ide-settings.component'; +import { DocumentationLinkComponent } from 'app/shared/components/documentation-link/documentation-link.component'; @NgModule({ - imports: [RouterModule.forChild(userSettingsState), ArtemisSharedModule, ArtemisSharedComponentModule, ClipboardModule, FormDateTimePickerModule], + imports: [RouterModule.forChild(userSettingsState), ArtemisSharedModule, ArtemisSharedComponentModule, ClipboardModule, FormDateTimePickerModule, DocumentationLinkComponent], declarations: [ UserSettingsContainerComponent, AccountInformationComponent, diff --git a/src/main/webapp/app/shared/user-settings/user-settings.scss b/src/main/webapp/app/shared/user-settings/user-settings.scss index 02a29cc3ae86..15bc60da27ed 100644 --- a/src/main/webapp/app/shared/user-settings/user-settings.scss +++ b/src/main/webapp/app/shared/user-settings/user-settings.scss @@ -19,11 +19,6 @@ dd { font-size: larger; } -span { - color: gray; - font-size: smaller; -} - .userSettings-info { span { font-style: italic; diff --git a/src/main/webapp/app/shared/util/os-detector.util.ts b/src/main/webapp/app/shared/util/os-detector.util.ts new file mode 100644 index 000000000000..59ef5027b69b --- /dev/null +++ b/src/main/webapp/app/shared/util/os-detector.util.ts @@ -0,0 +1,17 @@ +export function getOS(): string { + const userAgent = window.navigator.userAgent; + + if (userAgent.indexOf('Win') !== -1) { + return 'Windows'; + } else if (userAgent.indexOf('Mac') !== -1) { + return 'MacOS'; + } else if (/Android/.test(userAgent)) { + return 'Android'; + } else if (userAgent.indexOf('Linux') !== -1) { + return 'Linux'; + } else if (/iPhone|iPad|iPod/.test(userAgent)) { + return 'iOS'; + } else { + return 'Unknown'; + } +} diff --git a/src/main/webapp/content/scss/themes/_dark-variables.scss b/src/main/webapp/content/scss/themes/_dark-variables.scss index 40789ff78781..daa039c0e167 100644 --- a/src/main/webapp/content/scss/themes/_dark-variables.scss +++ b/src/main/webapp/content/scss/themes/_dark-variables.scss @@ -662,3 +662,10 @@ $iris-rate-background: var(--neutral-dark-l-15); // Image Cropper $cropper-overlay-color: transparent; + +// Settings +$ssh-key-table-hover-background: $gray-900; +$ssh-key-settings-dropdown-buttons: $gray-800; +$ssh-key-settings-dropdown-buttons-hover: $gray-700; +$ssh-key-settings-text-color: $white; +$ssh-key-settings-shadow: rgba(0, 0, 0, 0.5); diff --git a/src/main/webapp/content/scss/themes/_default-variables.scss b/src/main/webapp/content/scss/themes/_default-variables.scss index 92eca3165ea0..337a58ed3316 100644 --- a/src/main/webapp/content/scss/themes/_default-variables.scss +++ b/src/main/webapp/content/scss/themes/_default-variables.scss @@ -590,3 +590,10 @@ $iris-rate-background: var(--gray-300); // Image Cropper $cropper-overlay-color: transparent; + +// Settings +$ssh-key-settings-table-hover-background: $gray-200; +$ssh-key-settings-dropdown-buttons: $light; +$ssh-key-settings-dropdown-buttons-hover: $gray-200; +$ssh-key-settings-text-color: $black; +$ssh-key-settings-shadow: rgba(0, 0, 0, 0.2); diff --git a/src/main/webapp/i18n/de/userSettings.json b/src/main/webapp/i18n/de/userSettings.json index 1d5cbdacc854..6a2ebecc1763 100644 --- a/src/main/webapp/i18n/de/userSettings.json +++ b/src/main/webapp/i18n/de/userSettings.json @@ -27,11 +27,22 @@ "deleteFailure": "SSH-Schlüssel konnte nicht gelöscht werden.", "deleteSuccess": "SSH-Schlüssel erfolgreich gelöscht.", "cancelSavingSshKey": "Abbrechen", - "editExistingSshKey": "SSH-Schlüssel bearbeiten", - "addNewSshKey": "Neuer SSH-Schlüssel", + "editExistingSshKey": "Bearbeiten", + "viewExistingSshKey": "Ansehen", + "addNewSshKey": "SSH-Schlüssel hinzufügen", + "sshKeyDetails": "SSH-Schlüssel Informationen", "deleteSshKey": "Löschen", "sshKeyDisplayedInformation": "Das ist dein aktuell konfigurierter SSH-Schlüssel:", - "key": "SSH Schlüssel" + "key": "Schlüssel", + "noKeysHaveBeenAdded": "Es wurden noch keine SSH-Schlüssel hinzugefügt", + "whatToUseSSHForInfo": "Verwende SSH-Schlüssel, um einfach und sicher eine Verbindung zu Repositories herzustellen.", + "learnMore": "Erfahre mehr über SSH-Schlüssel", + "alreadyHaveKey": "Du hast bereits einen Schlüssel? Kopiere deinen Schlüssel in die Zwischenablage:", + "back": "Zurück", + "keysTablePageTitle": "SSH-Schlüssel", + "keys": "Schlüssel", + "actions": "Aktionen", + "keyName": "Key 1 (Aktuell wird nur ein Schlüssel unterstützt)" }, "vcsAccessTokensSettingsPage": { "addTokenTitle": "Neues Zugriffstoken erzeugen", @@ -56,7 +67,7 @@ "joinedArtemis": "Artemis beigetreten am", "profilePicture": "Profilbild", "addProfilePicture": "Profilbild hinzufügen", - "sshKey": "Öffentlicher SSH Schlüssel" + "sshKey": "Öffentlicher SSH-Schlüssel" }, "categories": { "NOTIFICATION_SETTINGS": "Benachrichtigungseinstellungen", diff --git a/src/main/webapp/i18n/en/userSettings.json b/src/main/webapp/i18n/en/userSettings.json index 328be05e823a..29afe83a0c3c 100644 --- a/src/main/webapp/i18n/en/userSettings.json +++ b/src/main/webapp/i18n/en/userSettings.json @@ -27,11 +27,22 @@ "deleteFailure": "Failed to delete SSH key.", "deleteSuccess": "Successfully deleted SSH key.", "cancelSavingSshKey": "Cancel", - "editExistingSshKey": "Edit SSH key", - "addNewSshKey": "New SSH key", + "editExistingSshKey": "Edit", + "viewExistingSshKey": "View", + "addNewSshKey": "Add SSH key", + "sshKeyDetails": "SSH key details", "deleteSshKey": "Delete", "sshKeyDisplayedInformation": "This is your currently configured SSH key:", - "key": "SSH Key" + "key": "Key", + "noKeysHaveBeenAdded": "No SSH keys have been added", + "whatToUseSSHForInfo": "Use SSH keys to connect simply and securely to repositories.", + "learnMore": "Learn more about SSH keys", + "alreadyHaveKey": "Already have a key? Copy your key to the clipboard:", + "back": "Back", + "keysTablePageTitle": "SSH keys", + "keys": "Keys", + "actions": "Actions", + "keyName": "Key 1 (at the moment you can only have one key)" }, "vcsAccessTokensSettingsPage": { "addTokenTitle": "Add personal access token", diff --git a/src/test/javascript/spec/component/account/ssh-user-settings.component.spec.ts b/src/test/javascript/spec/component/account/ssh-user-settings.component.spec.ts index c7b44b4f9e97..29b647dfb946 100644 --- a/src/test/javascript/spec/component/account/ssh-user-settings.component.spec.ts +++ b/src/test/javascript/spec/component/account/ssh-user-settings.component.spec.ts @@ -76,10 +76,10 @@ describe('SshUserSettingsComponent', () => { accountServiceMock.addSshPublicKey.mockReturnValue(of(new HttpResponse({ status: 200 }))); comp.ngOnInit(); comp.sshKey = 'new-key'; - comp.editSshKey = true; + comp.showSshKey = true; comp.saveSshKey(); expect(accountServiceMock.addSshPublicKey).toHaveBeenCalledWith('new-key'); - expect(comp.editSshKey).toBeFalse(); + expect(comp.showSshKey).toBeFalse(); }); it('should delete SSH key and disable edit mode', () => { @@ -87,10 +87,10 @@ describe('SshUserSettingsComponent', () => { comp.ngOnInit(); const empty = ''; comp.sshKey = 'new-key'; - comp.editSshKey = true; + comp.showSshKey = true; comp.deleteSshKey(); expect(accountServiceMock.deleteSshPublicKey).toHaveBeenCalled(); - expect(comp.editSshKey).toBeFalse(); + expect(comp.showSshKey).toBeFalse(); expect(comp.storedSshKey).toEqual(empty); }); @@ -113,12 +113,48 @@ describe('SshUserSettingsComponent', () => { const oldKey = 'old-key'; const newKey = 'new-key'; comp.sshKey = oldKey; - comp.editSshKey = true; + comp.showSshKey = true; comp.saveSshKey(); expect(comp.storedSshKey).toEqual(oldKey); - comp.editSshKey = true; + comp.showSshKey = true; comp.sshKey = newKey; comp.cancelEditingSshKey(); expect(comp.storedSshKey).toEqual(oldKey); }); + + it('should detect Windows', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'); + comp.ngOnInit(); + expect(comp.copyInstructions).toBe('cat ~/.ssh/id_ed25519.pub | clip'); + }); + + it('should detect MacOS', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'); + comp.ngOnInit(); + expect(comp.copyInstructions).toBe('pbcopy < ~/.ssh/id_ed25519.pub'); + }); + + it('should detect Linux', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (X11; Linux x86_64)'); + comp.ngOnInit(); + expect(comp.copyInstructions).toBe('xclip -selection clipboard < ~/.ssh/id_ed25519.pub'); + }); + + it('should detect Android', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Linux; Android 10; Pixel 3)'); + comp.ngOnInit(); + expect(comp.copyInstructions).toBe('termux-clipboard-set < ~/.ssh/id_ed25519.pub'); + }); + + it('should detect iOS', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 13_5)'); + comp.ngOnInit(); + expect(comp.copyInstructions).toBe('Ctrl + C'); + }); + + it('should return Unknown for unrecognized OS', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Unknown OS)'); + comp.ngOnInit(); + expect(comp.copyInstructions).toBe('Ctrl + C'); + }); }); From 491e368a8052d9c6fd2e60d1904d90f46aed8ba2 Mon Sep 17 00:00:00 2001 From: Ole Vester <73833780+ole-ve@users.noreply.github.com> Date: Sat, 12 Oct 2024 17:30:12 +0200 Subject: [PATCH 06/23] Exam mode: Add summary to exam deletion dialog (#9185) --- .../artemis/communication/domain/Post.java | 4 + .../repository/AnswerPostRepository.java | 2 + .../repository/PostRepository.java | 4 + .../core/dto/CourseDeletionSummaryDTO.java | 7 + .../artemis/core/service/CourseService.java | 33 +++- .../core/web/admin/AdminCourseResource.java | 16 ++ .../exam/dto/ExamDeletionSummaryDTO.java | 8 + .../repository/StudentExamRepository.java | 4 + .../exam/service/ExamDeletionService.java | 48 +++++- .../aet/artemis/exam/web/ExamResource.java | 17 ++ .../repository/BuildJobRepository.java | 15 ++ .../ProgrammingExerciseRepository.java | 2 + .../app/course/manage/course-admin.service.ts | 9 ++ .../course-management-tab-bar.component.html | 2 + .../course-management-tab-bar.component.ts | 68 +++++++- .../entities/course-deletion-summary.model.ts | 5 + .../entities/exam-deletion-summary.model.ts | 9 ++ .../exam/manage/exam-management.service.ts | 10 ++ .../manage/exams/exam-detail.component.html | 2 + .../manage/exams/exam-detail.component.ts | 64 +++++++- .../delete-dialog/delete-button.directive.ts | 6 +- .../delete-dialog.component.html | 16 ++ .../delete-dialog/delete-dialog.component.ts | 4 +- .../delete-dialog/delete-dialog.model.ts | 14 ++ .../delete-dialog/delete-dialog.service.ts | 23 ++- src/main/webapp/i18n/de/course.json | 21 ++- src/main/webapp/i18n/de/exam.json | 21 ++- src/main/webapp/i18n/en/course.json | 21 ++- src/main/webapp/i18n/en/exam.json | 21 ++- .../util/ConversationUtilService.java | 1 + .../core/service/CourseServiceTest.java | 52 +++++++ .../exam/ExamDeletionIntegrationTest.java | 146 ++++++++++++++++++ .../artemis/exam/util/ExamUtilService.java | 41 +++++ 33 files changed, 703 insertions(+), 13 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/core/dto/CourseDeletionSummaryDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/exam/dto/ExamDeletionSummaryDTO.java create mode 100644 src/main/webapp/app/entities/course-deletion-summary.model.ts create mode 100644 src/main/webapp/app/entities/exam-deletion-summary.model.ts create mode 100644 src/test/java/de/tum/cit/aet/artemis/exam/ExamDeletionIntegrationTest.java diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java index d3bf4e217ea3..4ff2d48fedf5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Post.java @@ -167,6 +167,10 @@ public void addTag(String tag) { this.tags.add(tag); } + public void setCourse(Course course) { + this.course = course; + } + public Conversation getConversation() { return conversation; } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java index c6a915b994e2..db61138b3a73 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/AnswerPostRepository.java @@ -30,4 +30,6 @@ default AnswerPost findAnswerPostByIdElseThrow(Long answerPostId) { default AnswerPost findAnswerMessageByIdElseThrow(Long answerPostId) { return getValueElseThrow(findById(answerPostId).filter(answerPost -> answerPost.getPost().getConversation() != null), answerPostId); } + + long countAnswerPostsByPostIdIn(List postIds); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/PostRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/PostRepository.java index 449a629fb4af..aacfbc33d179 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/PostRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/PostRepository.java @@ -45,4 +45,8 @@ default Post findPostByIdElseThrow(Long postId) throws EntityNotFoundException { default Post findPostOrMessagePostByIdElseThrow(Long postId) throws EntityNotFoundException { return getValueElseThrow(findById(postId), postId); } + + List findAllByConversationId(Long conversationId); + + List findAllByCourseId(Long courseId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseDeletionSummaryDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseDeletionSummaryDTO.java new file mode 100644 index 000000000000..2f3b8d51596c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/CourseDeletionSummaryDTO.java @@ -0,0 +1,7 @@ +package de.tum.cit.aet.artemis.core.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record CourseDeletionSummaryDTO(long numberOfBuilds, long numberOfCommunicationPosts, long numberOfAnswerPosts) { +} 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 01bb68edc441..cba16e58ad7e 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 @@ -58,9 +58,12 @@ import de.tum.cit.aet.artemis.atlas.repository.PrerequisiteRepository; import de.tum.cit.aet.artemis.atlas.service.learningpath.LearningPathService; import de.tum.cit.aet.artemis.communication.domain.NotificationType; +import de.tum.cit.aet.artemis.communication.domain.Post; import de.tum.cit.aet.artemis.communication.domain.notification.GroupNotification; +import de.tum.cit.aet.artemis.communication.repository.AnswerPostRepository; import de.tum.cit.aet.artemis.communication.repository.FaqRepository; import de.tum.cit.aet.artemis.communication.repository.GroupNotificationRepository; +import de.tum.cit.aet.artemis.communication.repository.PostRepository; import de.tum.cit.aet.artemis.communication.repository.conversation.ConversationRepository; import de.tum.cit.aet.artemis.communication.service.notifications.GroupNotificationService; import de.tum.cit.aet.artemis.core.config.Constants; @@ -68,6 +71,7 @@ import de.tum.cit.aet.artemis.core.domain.DomainObject; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.core.dto.CourseContentCount; +import de.tum.cit.aet.artemis.core.dto.CourseDeletionSummaryDTO; import de.tum.cit.aet.artemis.core.dto.CourseManagementDetailViewDTO; import de.tum.cit.aet.artemis.core.dto.DueDateStat; import de.tum.cit.aet.artemis.core.dto.SearchResultPageDTO; @@ -104,6 +108,7 @@ import de.tum.cit.aet.artemis.plagiarism.domain.PlagiarismCase; import de.tum.cit.aet.artemis.plagiarism.repository.PlagiarismCaseRepository; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.repository.BuildJobRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; import de.tum.cit.aet.artemis.tutorialgroup.repository.TutorialGroupNotificationRepository; import de.tum.cit.aet.artemis.tutorialgroup.repository.TutorialGroupRepository; @@ -201,6 +206,12 @@ public class CourseService { private final TutorialGroupNotificationRepository tutorialGroupNotificationRepository; + private final PostRepository postRepository; + + private final AnswerPostRepository answerPostRepository; + + private final BuildJobRepository buildJobRepository; + public CourseService(CourseRepository courseRepository, ExerciseService exerciseService, ExerciseDeletionService exerciseDeletionService, AuthorizationCheckService authCheckService, UserRepository userRepository, LectureService lectureService, GroupNotificationRepository groupNotificationRepository, ExerciseGroupRepository exerciseGroupRepository, AuditEventRepository auditEventRepository, UserService userService, ExamDeletionService examDeletionService, @@ -213,7 +224,8 @@ public CourseService(CourseRepository courseRepository, ExerciseService exercise TutorialGroupRepository tutorialGroupRepository, PlagiarismCaseRepository plagiarismCaseRepository, ConversationRepository conversationRepository, LearningPathService learningPathService, Optional irisSettingsService, LectureRepository lectureRepository, TutorialGroupNotificationRepository tutorialGroupNotificationRepository, TutorialGroupChannelManagementService tutorialGroupChannelManagementService, - PrerequisiteRepository prerequisiteRepository, CompetencyRelationRepository competencyRelationRepository, FaqRepository faqRepository) { + PrerequisiteRepository prerequisiteRepository, CompetencyRelationRepository competencyRelationRepository, PostRepository postRepository, + AnswerPostRepository answerPostRepository, BuildJobRepository buildJobRepository, FaqRepository faqRepository) { this.courseRepository = courseRepository; this.exerciseService = exerciseService; this.exerciseDeletionService = exerciseDeletionService; @@ -253,6 +265,9 @@ public CourseService(CourseRepository courseRepository, ExerciseService exercise this.tutorialGroupChannelManagementService = tutorialGroupChannelManagementService; this.prerequisiteRepository = prerequisiteRepository; this.competencyRelationRepository = competencyRelationRepository; + this.buildJobRepository = buildJobRepository; + this.postRepository = postRepository; + this.answerPostRepository = answerPostRepository; this.faqRepository = faqRepository; } @@ -444,6 +459,22 @@ public Set findAllOnlineCoursesForPlatformForUser(String registrationId, .collect(Collectors.toSet()); } + /** + * Get the course deletion summary for the given course. + * + * @param course the course for which to get the deletion summary + * @return the course deletion summary + */ + public CourseDeletionSummaryDTO getDeletionSummary(Course course) { + List programmingExerciseIds = course.getExercises().stream().map(Exercise::getId).toList(); + long numberOfBuilds = buildJobRepository.countBuildJobsByExerciseIds(programmingExerciseIds); + + List posts = postRepository.findAllByCourseId(course.getId()); + long numberOfCommunicationPosts = posts.size(); + long numberOfAnswerPosts = answerPostRepository.countAnswerPostsByPostIdIn(posts.stream().map(Post::getId).toList()); + return new CourseDeletionSummaryDTO(numberOfBuilds, numberOfCommunicationPosts, numberOfAnswerPosts); + } + /** * Deletes all elements associated with the course including: *
    diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCourseResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCourseResource.java index 9cdcc9486f52..b83f757209c4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCourseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminCourseResource.java @@ -34,6 +34,7 @@ import de.tum.cit.aet.artemis.core.config.Constants; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.dto.CourseDeletionSummaryDTO; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; import de.tum.cit.aet.artemis.core.repository.CourseRepository; import de.tum.cit.aet.artemis.core.repository.UserRepository; @@ -181,6 +182,21 @@ public ResponseEntity deleteCourse(@PathVariable long courseId) { return ResponseEntity.ok().headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, Course.ENTITY_NAME, course.getTitle())).build(); } + /** + * GET /courses/:courseId/deletion-summary : get the deletion summary for the course with the given id. + * + * @param courseId the id of the course + * @return the ResponseEntity with status 200 (OK) and the deletion summary in the body + */ + @GetMapping("courses/{courseId}/deletion-summary") + @EnforceAdmin + public ResponseEntity getDeletionSummary(@PathVariable long courseId) { + log.debug("REST request to get deletion summary course: {}", courseId); + final Course course = courseRepository.findByIdWithEagerExercisesElseThrow(courseId); + + return ResponseEntity.ok().body(courseService.getDeletionSummary(course)); + } + /** * Creates a default channel with the given name and adds all students, tutors and instructors as participants. * diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/dto/ExamDeletionSummaryDTO.java b/src/main/java/de/tum/cit/aet/artemis/exam/dto/ExamDeletionSummaryDTO.java new file mode 100644 index 000000000000..c0cd6608ec2c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/exam/dto/ExamDeletionSummaryDTO.java @@ -0,0 +1,8 @@ +package de.tum.cit.aet.artemis.exam.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record ExamDeletionSummaryDTO(long numberOfBuilds, long numberOfCommunicationPosts, long numberOfAnswerPosts, long numberRegisteredStudents, long numberNotStartedExams, + long numberStartedExams, long numberSubmittedExams) { +} diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/repository/StudentExamRepository.java b/src/main/java/de/tum/cit/aet/artemis/exam/repository/StudentExamRepository.java index 2236d4d2035c..4d3bcfe61369 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/repository/StudentExamRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/repository/StudentExamRepository.java @@ -245,6 +245,10 @@ SELECT MAX(se.workingTime) """) Set findAllUnsubmittedWithExercisesByExamId(@Param("examId") Long examId); + List findAllByExamId(Long examId); + + List findAllByExamId_AndTestRunIsTrue(Long examId); + @Query(""" SELECT DISTINCT se FROM StudentExam se diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamDeletionService.java b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamDeletionService.java index a8fd46512528..1c05e1d4be65 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamDeletionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamDeletionService.java @@ -20,7 +20,10 @@ import de.tum.cit.aet.artemis.assessment.domain.GradingScale; import de.tum.cit.aet.artemis.assessment.repository.GradingScaleRepository; +import de.tum.cit.aet.artemis.communication.domain.Post; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; +import de.tum.cit.aet.artemis.communication.repository.AnswerPostRepository; +import de.tum.cit.aet.artemis.communication.repository.PostRepository; import de.tum.cit.aet.artemis.communication.repository.conversation.ChannelRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ChannelService; import de.tum.cit.aet.artemis.core.config.Constants; @@ -29,13 +32,16 @@ import de.tum.cit.aet.artemis.exam.domain.Exam; import de.tum.cit.aet.artemis.exam.domain.ExerciseGroup; import de.tum.cit.aet.artemis.exam.domain.StudentExam; +import de.tum.cit.aet.artemis.exam.dto.ExamDeletionSummaryDTO; import de.tum.cit.aet.artemis.exam.repository.ExamLiveEventRepository; import de.tum.cit.aet.artemis.exam.repository.ExamRepository; import de.tum.cit.aet.artemis.exam.repository.StudentExamRepository; import de.tum.cit.aet.artemis.exercise.domain.Exercise; +import de.tum.cit.aet.artemis.exercise.domain.ExerciseType; import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository; import de.tum.cit.aet.artemis.exercise.service.ExerciseDeletionService; import de.tum.cit.aet.artemis.exercise.service.ParticipationService; +import de.tum.cit.aet.artemis.programming.repository.BuildJobRepository; import de.tum.cit.aet.artemis.quiz.domain.QuizPool; import de.tum.cit.aet.artemis.quiz.repository.QuizPoolRepository; @@ -71,10 +77,17 @@ public class ExamDeletionService { private final QuizPoolRepository quizPoolRepository; + private final BuildJobRepository buildJobRepository; + + private final PostRepository postRepository; + + private final AnswerPostRepository answerPostRepository; + public ExamDeletionService(ExerciseDeletionService exerciseDeletionService, ParticipationService participationService, CacheManager cacheManager, UserRepository userRepository, ExamRepository examRepository, AuditEventRepository auditEventRepository, StudentExamRepository studentExamRepository, GradingScaleRepository gradingScaleRepository, StudentParticipationRepository studentParticipationRepository, ChannelRepository channelRepository, ChannelService channelService, - ExamLiveEventRepository examLiveEventRepository, QuizPoolRepository quizPoolRepository) { + ExamLiveEventRepository examLiveEventRepository, QuizPoolRepository quizPoolRepository, BuildJobRepository buildJobRepository, PostRepository postRepository, + AnswerPostRepository answerPostRepository) { this.exerciseDeletionService = exerciseDeletionService; this.participationService = participationService; this.cacheManager = cacheManager; @@ -88,6 +101,9 @@ public ExamDeletionService(ExerciseDeletionService exerciseDeletionService, Part this.channelService = channelService; this.examLiveEventRepository = examLiveEventRepository; this.quizPoolRepository = quizPoolRepository; + this.buildJobRepository = buildJobRepository; + this.postRepository = postRepository; + this.answerPostRepository = answerPostRepository; } /** @@ -240,4 +256,34 @@ public void deleteTestRun(Long testRunId) { log.info("Request to delete Test Run {}", testRunId); studentExamRepository.deleteById(testRunId); } + + /** + * Get the exam deletion summary for the given exam. + * + * @param examId the ID of the exam for which the deletion summary should be fetched + * @return the exam deletion summary + */ + public ExamDeletionSummaryDTO getExamDeletionSummary(@NotNull long examId) { + Exam exam = examRepository.findOneWithEagerExercisesGroupsAndStudentExams(examId); + long numberOfBuilds = exam.getExerciseGroups().stream().flatMap(group -> group.getExercises().stream()) + .filter(exercise -> ExerciseType.PROGRAMMING.equals(exercise.getExerciseType())) + .mapToLong(exercise -> buildJobRepository.countBuildJobsByExerciseIds(List.of(exercise.getId()))).sum(); + + Channel channel = channelRepository.findChannelByExamId(examId); + Long conversationId = channel.getId(); + + List postIds = postRepository.findAllByConversationId(conversationId).stream().map(Post::getId).toList(); + long numberOfCommunicationPosts = postIds.size(); + long numberOfAnswerPosts = answerPostRepository.countAnswerPostsByPostIdIn(postIds); + + Set studentExams = exam.getStudentExams(); + long numberRegisteredStudents = studentExams.size(); + + // Boolean.TRUE/Boolean.FALSE are used to handle the case where isStarted/isSubmitted is null + long notStartedExams = studentExams.stream().filter(studentExam -> studentExam.isStarted() == null || !studentExam.isStarted()).count(); + long startedExams = studentExams.stream().filter(studentExam -> Boolean.TRUE.equals(studentExam.isStarted())).count(); + long submittedExams = studentExams.stream().filter(studentExam -> Boolean.TRUE.equals(studentExam.isStarted()) && Boolean.TRUE.equals(studentExam.isSubmitted())).count(); + + return new ExamDeletionSummaryDTO(numberOfBuilds, numberOfCommunicationPosts, numberOfAnswerPosts, numberRegisteredStudents, notStartedExams, startedExams, submittedExams); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java b/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java index 209f2a4fa040..8223ba8e54a9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/web/ExamResource.java @@ -77,6 +77,7 @@ 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.EnforceAtLeastTutor; +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.service.feature.Feature; import de.tum.cit.aet.artemis.core.service.feature.FeatureToggle; @@ -88,6 +89,7 @@ import de.tum.cit.aet.artemis.exam.domain.StudentExam; import de.tum.cit.aet.artemis.exam.domain.SuspiciousSessionsAnalysisOptions; import de.tum.cit.aet.artemis.exam.dto.ExamChecklistDTO; +import de.tum.cit.aet.artemis.exam.dto.ExamDeletionSummaryDTO; import de.tum.cit.aet.artemis.exam.dto.ExamInformationDTO; import de.tum.cit.aet.artemis.exam.dto.ExamScoresDTO; import de.tum.cit.aet.artemis.exam.dto.ExamUserDTO; @@ -1320,4 +1322,19 @@ public ResponseEntity> getAllSuspiciousExamSessio analyzeSessionsIpOutsideOfRange); return ResponseEntity.ok(examSessionService.retrieveAllSuspiciousExamSessionsByExamId(examId, options, Optional.ofNullable(ipSubnet))); } + + /** + * GET /courses/{courseId}/exams/{examId}/deletion-summary : Get a summary of the deletion of an exam. + * + * @param courseId the id of the course + * @param examId the id of the exam + * + * @return the ResponseEntity with status 200 (OK) and with body a summary of the deletion of the exam + */ + @GetMapping("courses/{courseId}/exams/{examId}/deletion-summary") + @EnforceAtLeastInstructorInCourse + public ResponseEntity getDeletionSummary(@PathVariable long courseId, @PathVariable long examId) { + log.debug("REST request to get deletion summary for exam : {}", examId); + return ResponseEntity.ok(examDeletionService.getExamDeletionSummary(examId)); + } } 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 7c341585f60f..d7a5e662c744 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 @@ -89,4 +89,19 @@ default BuildJob findByBuildJobIdElseThrow(String buildJobId) { return getValueElseThrow(findByBuildJobId(buildJobId)); } + /** + * Get the number of build jobs for a list of exercise ids. + * + * @param exerciseIds the list of exercise ids + * @return the number of build jobs + */ + @Query(""" + SELECT COUNT(b) + FROM BuildJob b + LEFT JOIN Result r ON b.result.id = r.id + LEFT JOIN Participation p ON r.participation.id = p.id + LEFT JOIN Exercise e ON p.exercise.id = e.id + WHERE e.id IN :exerciseIds + """) + long countBuildJobsByExerciseIds(@Param("exerciseIds") List exerciseIds); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java index ab64ca6e53a8..579d714b18a8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseRepository.java @@ -116,6 +116,8 @@ Optional findWithTemplateAndSolutionParticipationTeamAssign List findAllByProjectKey(String projectKey); + List findAllByCourseId(Long courseId); + @EntityGraph(type = LOAD, attributePaths = "submissionPolicy") List findWithSubmissionPolicyByProjectKey(String projectKey); diff --git a/src/main/webapp/app/course/manage/course-admin.service.ts b/src/main/webapp/app/course/manage/course-admin.service.ts index fe7ceef20670..1185c439a8d2 100644 --- a/src/main/webapp/app/course/manage/course-admin.service.ts +++ b/src/main/webapp/app/course/manage/course-admin.service.ts @@ -5,6 +5,7 @@ import { map } from 'rxjs/operators'; import { Course } from 'app/entities/course.model'; import { objectToJsonBlob } from 'app/utils/blob-util'; import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { CourseDeletionSummaryDTO } from 'app/entities/course-deletion-summary.model'; export type EntityResponseType = HttpResponse; export type EntityArrayResponseType = HttpResponse; @@ -51,4 +52,12 @@ export class CourseAdminService { delete(courseId: number): Observable> { return this.http.delete(`${this.resourceUrl}/${courseId}`, { observe: 'response' }); } + + /** + * Returns a summary for the course providing information potentially relevant for the deletion. + * @param courseId - the id of the course to get the deletion summary for + */ + getDeletionSummary(courseId: number): Observable> { + return this.http.get(`${this.resourceUrl}/${courseId}/deletion-summary`, { observe: 'response' }); + } } diff --git a/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html b/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html index abbfdaf7c010..bc10fd753a7c 100644 --- a/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html +++ b/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.html @@ -107,6 +107,8 @@ [buttonSize]="ButtonSize.MEDIUM" jhiDeleteButton [entityTitle]="course.title || ''" + entitySummaryTitle="artemisApp.course.delete.summary.title" + [fetchEntitySummary]="fetchCourseDeletionSummary()" deleteQuestion="artemisApp.course.delete.question" deleteConfirmationText="artemisApp.course.delete.typeNameToConfirm" (delete)="deleteCourse(course.id!)" diff --git a/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.ts b/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.ts index c25c182067e3..e4b3865d3dbc 100644 --- a/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.ts +++ b/src/main/webapp/app/course/manage/course-management-tab-bar/course-management-tab-bar.component.ts @@ -1,7 +1,7 @@ import { AfterViewInit, Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; -import { Subject, Subscription } from 'rxjs'; +import { Observable, Subject, Subscription, map, of } from 'rxjs'; import { Course, isCommunicationEnabled } from 'app/entities/course.model'; import { CourseManagementService } from 'app/course/manage/course-management.service'; import { ButtonSize } from 'app/shared/components/button.component'; @@ -34,6 +34,8 @@ import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { PROFILE_IRIS, PROFILE_LOCALCI, PROFILE_LTI } from 'app/app.constants'; import { CourseAccessStorageService } from 'app/course/course-access-storage.service'; import { scrollToTopOfPage } from 'app/shared/util/utils'; +import { ExerciseType } from 'app/entities/exercise.model'; +import { EntitySummary } from 'app/shared/delete-dialog/delete-dialog.model'; @Component({ selector: 'jhi-course-management-tab-bar', @@ -200,4 +202,68 @@ export class CourseManagementTabBarComponent implements OnInit, OnDestroy, After const courseManagementRegex = /course-management\/[0-9]+(\/edit)?$/; return courseManagementRegex.test(this.router.url); } + + private getExistingSummaryEntries(): EntitySummary { + const numberRepositories = + this.course?.exercises + ?.filter((exercise) => exercise.type === 'programming') + .map((exercise) => exercise?.numberOfParticipations ?? 0) + .reduce((repositorySum, numberOfParticipationsForRepository) => repositorySum + numberOfParticipationsForRepository, 0) ?? 0; + + const numberOfExercisesPerType = new Map(); + this.course?.exercises?.forEach((exercise) => { + if (exercise.type === undefined) { + return; + } + const oldValue = numberOfExercisesPerType.get(exercise.type) ?? 0; + numberOfExercisesPerType.set(exercise.type, oldValue + 1); + }); + + const numberExams = this.course?.numberOfExams ?? 0; + const numberLectures = this.course?.lectures?.length ?? 0; + const numberStudents = this.course?.numberOfStudents ?? 0; + const numberTutors = this.course?.numberOfTeachingAssistants ?? 0; + const numberEditors = this.course?.numberOfEditors ?? 0; + const numberInstructors = this.course?.numberOfInstructors ?? 0; + const isTestCourse = this.course?.testCourse; + + return { + 'artemisApp.course.delete.summary.numberRepositories': numberRepositories, + 'artemisApp.course.delete.summary.numberProgrammingExercises': numberOfExercisesPerType.get(ExerciseType.PROGRAMMING) ?? 0, + 'artemisApp.course.delete.summary.numberModelingExercises': numberOfExercisesPerType.get(ExerciseType.MODELING) ?? 0, + 'artemisApp.course.delete.summary.numberTextExercises': numberOfExercisesPerType.get(ExerciseType.TEXT) ?? 0, + 'artemisApp.course.delete.summary.numberFileUploadExercises': numberOfExercisesPerType.get(ExerciseType.FILE_UPLOAD) ?? 0, + 'artemisApp.course.delete.summary.numberQuizExercises': numberOfExercisesPerType.get(ExerciseType.QUIZ) ?? 0, + 'artemisApp.course.delete.summary.numberExams': numberExams, + 'artemisApp.course.delete.summary.numberLectures': numberLectures, + 'artemisApp.course.delete.summary.numberStudents': numberStudents, + 'artemisApp.course.delete.summary.numberTutors': numberTutors, + 'artemisApp.course.delete.summary.numberEditors': numberEditors, + 'artemisApp.course.delete.summary.numberInstructors': numberInstructors, + 'artemisApp.course.delete.summary.isTestCourse': isTestCourse, + }; + } + + fetchCourseDeletionSummary(): Observable { + if (this.course?.id === undefined) { + return of({}); + } + + return this.courseAdminService.getDeletionSummary(this.course.id).pipe( + map((response) => { + const summary = response.body; + + if (summary === null) { + return {}; + } + + return { + ...this.getExistingSummaryEntries(), + 'artemisApp.course.delete.summary.numberBuilds': summary.numberOfBuilds, + 'artemisApp.course.delete.summary.numberCommunicationPosts': summary.numberOfCommunicationPosts, + 'artemisApp.course.delete.summary.numberAnswerPosts': summary.numberOfAnswerPosts, + }; + }), + ); + } } diff --git a/src/main/webapp/app/entities/course-deletion-summary.model.ts b/src/main/webapp/app/entities/course-deletion-summary.model.ts new file mode 100644 index 000000000000..606a9d440d49 --- /dev/null +++ b/src/main/webapp/app/entities/course-deletion-summary.model.ts @@ -0,0 +1,5 @@ +export interface CourseDeletionSummaryDTO { + numberOfBuilds: number; + numberOfCommunicationPosts: number; + numberOfAnswerPosts: number; +} diff --git a/src/main/webapp/app/entities/exam-deletion-summary.model.ts b/src/main/webapp/app/entities/exam-deletion-summary.model.ts new file mode 100644 index 000000000000..ed98a32d1628 --- /dev/null +++ b/src/main/webapp/app/entities/exam-deletion-summary.model.ts @@ -0,0 +1,9 @@ +export interface ExamDeletionSummaryDTO { + numberOfBuilds: number; + numberOfCommunicationPosts: number; + numberOfAnswerPosts: number; + numberRegisteredStudents: number; + numberNotStartedExams: number; + numberStartedExams: number; + numberSubmittedExams: number; +} diff --git a/src/main/webapp/app/exam/manage/exam-management.service.ts b/src/main/webapp/app/exam/manage/exam-management.service.ts index ef0a74bd28fc..24425d3a530e 100644 --- a/src/main/webapp/app/exam/manage/exam-management.service.ts +++ b/src/main/webapp/app/exam/manage/exam-management.service.ts @@ -21,6 +21,7 @@ import { EntityTitleService, EntityType } from 'app/shared/layouts/navbar/entity import { ExamExerciseStartPreparationStatus } from 'app/exam/manage/student-exams/student-exams.component'; import { Exercise } from 'app/entities/exercise.model'; import { ExamWideAnnouncementEvent } from 'app/exam/participate/exam-participation-live-events.service'; +import { ExamDeletionSummaryDTO } from 'app/entities/exam-deletion-summary.model'; type EntityResponseType = HttpResponse; type EntityArrayResponseType = HttpResponse; @@ -208,6 +209,15 @@ export class ExamManagementService { ); } + /** + * Returns a summary for the exam providing information potentially relevant for the deletion. + * @param courseId The course id. + * @param examId The exam id. + */ + getDeletionSummary(courseId: number, examId: number): Observable> { + return this.http.get(`${this.resourceUrl}/${courseId}/exams/${examId}/deletion-summary`, { observe: 'response' }); + } + /** * Delete an exam on the server using a DELETE request. * @param courseId The course id. diff --git a/src/main/webapp/app/exam/manage/exams/exam-detail.component.html b/src/main/webapp/app/exam/manage/exams/exam-detail.component.html index 243126fc3758..18dfd2f261a6 100644 --- a/src/main/webapp/app/exam/manage/exams/exam-detail.component.html +++ b/src/main/webapp/app/exam/manage/exams/exam-detail.component.html @@ -87,6 +87,8 @@

    jhiDeleteButton [buttonSize]="buttonSize" [entityTitle]="exam.title || ''" + entitySummaryTitle="artemisApp.examManagement.delete.summary.title" + [fetchEntitySummary]="fetchExamDeletionSummary()" deleteQuestion="artemisApp.examManagement.delete.question" deleteConfirmationText="artemisApp.examManagement.delete.typeNameToConfirm" (delete)="deleteExam(exam.id!)" diff --git a/src/main/webapp/app/exam/manage/exams/exam-detail.component.ts b/src/main/webapp/app/exam/manage/exams/exam-detail.component.ts index 21638aa00238..c0f4130e83c1 100644 --- a/src/main/webapp/app/exam/manage/exams/exam-detail.component.ts +++ b/src/main/webapp/app/exam/manage/exams/exam-detail.component.ts @@ -2,9 +2,9 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { SafeHtml } from '@angular/platform-browser'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; -import { Subject } from 'rxjs'; +import { Observable, Subject, map } from 'rxjs'; import { Exam } from 'app/entities/exam/exam.model'; -import { ActionType } from 'app/shared/delete-dialog/delete-dialog.model'; +import { ActionType, EntitySummary } from 'app/shared/delete-dialog/delete-dialog.model'; import { ButtonSize } from 'app/shared/components/button.component'; import { ArtemisMarkdownService } from 'app/shared/markdown.service'; import { AccountService } from 'app/core/auth/account.service'; @@ -17,6 +17,7 @@ import { GradeType } from 'app/entities/grading-scale.model'; import { DetailOverviewSection, DetailType } from 'app/detail-overview-list/detail-overview-list.component'; import { ArtemisDurationFromSecondsPipe } from 'app/shared/pipes/artemis-duration-from-seconds.pipe'; import { scrollToTopOfPage } from 'app/shared/util/utils'; +import { ExerciseType } from 'app/entities/exercise.model'; @Component({ selector: 'jhi-exam-detail', @@ -160,4 +161,63 @@ export class ExamDetailComponent implements OnInit, OnDestroy { error: (error: HttpErrorResponse) => this.dialogErrorSource.next(error.message), }); } + + private getExistingSummaryEntries(): EntitySummary { + const numberOfProgrammingExerciseParticipations = + this.exam.exerciseGroups + ?.flatMap((exerciseGroup) => exerciseGroup.exercises) + .filter((exercise) => exercise?.type === ExerciseType.PROGRAMMING) + .map((exercise) => exercise?.numberOfParticipations ?? 0) + .reduce((repositorySum, numberOfParticipationsForRepository) => repositorySum + numberOfParticipationsForRepository, 0) ?? 0; + + const numberOfExercisesPerType = new Map(); + this.exam.exerciseGroups?.forEach((exerciseGroup) => { + exerciseGroup.exercises?.forEach((exercise) => { + if (exercise.type === undefined) { + return; + } + const oldValue = numberOfExercisesPerType.get(exercise.type) ?? 0; + numberOfExercisesPerType.set(exercise.type, oldValue + 1); + }); + }); + + const numberOfExerciseGroups = this.exam.exerciseGroups?.length ?? 0; + const isTestExam = this.exam.testExam ?? false; + const isTestCourse = this.exam.course?.testCourse ?? false; + + return { + 'artemisApp.examManagement.delete.summary.numberExerciseGroups': numberOfExerciseGroups, + 'artemisApp.examManagement.delete.summary.numberProgrammingExercises': numberOfExercisesPerType.get(ExerciseType.PROGRAMMING), + 'artemisApp.examManagement.delete.summary.numberModelingExercises': numberOfExercisesPerType.get(ExerciseType.MODELING), + 'artemisApp.examManagement.delete.summary.numberTextExercises': numberOfExercisesPerType.get(ExerciseType.TEXT), + 'artemisApp.examManagement.delete.summary.numberFileUploadExercises': numberOfExercisesPerType.get(ExerciseType.FILE_UPLOAD), + 'artemisApp.examManagement.delete.summary.numberQuizExercises': numberOfExercisesPerType.get(ExerciseType.QUIZ), + 'artemisApp.examManagement.delete.summary.numberRepositories': numberOfProgrammingExerciseParticipations, + 'artemisApp.examManagement.delete.summary.isTestExam': isTestExam, + 'artemisApp.examManagement.delete.summary.isTestCourse': isTestCourse, + }; + } + + fetchExamDeletionSummary(): Observable { + return this.examManagementService.getDeletionSummary(this.exam.course!.id!, this.exam.id!).pipe( + map((response) => { + const summary = response.body; + + if (summary === null) { + return {}; + } + + return { + ...this.getExistingSummaryEntries(), + 'artemisApp.examManagement.delete.summary.numberBuilds': summary.numberOfBuilds, + 'artemisApp.examManagement.delete.summary.numberRegisteredStudents': summary.numberRegisteredStudents, + 'artemisApp.examManagement.delete.summary.numberNotStartedExams': summary.numberNotStartedExams, + 'artemisApp.examManagement.delete.summary.numberStartedExams': summary.numberStartedExams, + 'artemisApp.examManagement.delete.summary.numberSubmittedExams': summary.numberSubmittedExams, + 'artemisApp.examManagement.delete.summary.numberCommunicationPosts': summary.numberOfCommunicationPosts, + 'artemisApp.examManagement.delete.summary.numberAnswerPosts': summary.numberOfAnswerPosts, + }; + }), + ); + } } diff --git a/src/main/webapp/app/shared/delete-dialog/delete-button.directive.ts b/src/main/webapp/app/shared/delete-dialog/delete-button.directive.ts index 4f1476ae508b..6fe508245dc8 100644 --- a/src/main/webapp/app/shared/delete-dialog/delete-button.directive.ts +++ b/src/main/webapp/app/shared/delete-dialog/delete-button.directive.ts @@ -1,7 +1,7 @@ import { DeleteDialogService } from 'app/shared/delete-dialog/delete-dialog.service'; import { Directive, ElementRef, EventEmitter, HostListener, Input, OnInit, Output, Renderer2 } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { ActionType, DeleteDialogData } from 'app/shared/delete-dialog/delete-dialog.model'; +import { ActionType, DeleteDialogData, EntitySummary } from 'app/shared/delete-dialog/delete-dialog.model'; import { Observable } from 'rxjs'; import { ButtonSize, ButtonType } from 'app/shared/components/button.component'; @@ -9,6 +9,8 @@ import { ButtonSize, ButtonType } from 'app/shared/components/button.component'; export class DeleteButtonDirective implements OnInit { @Input() entityTitle?: string; @Input() deleteQuestion: string; + @Input() entitySummaryTitle?: string; + @Input() fetchEntitySummary?: Observable; @Input() translateValues: { [key: string]: unknown } = {}; @Input() deleteConfirmationText: string; @Input() buttonSize: ButtonSize = ButtonSize.SMALL; @@ -73,6 +75,8 @@ export class DeleteButtonDirective implements OnInit { translateValues: this.translateValues, deleteConfirmationText: this.deleteConfirmationText, additionalChecks: this.additionalChecks, + entitySummaryTitle: this.entitySummaryTitle, + fetchEntitySummary: this.fetchEntitySummary, actionType: this.actionType, buttonType: this.buttonType, delete: this.delete, diff --git a/src/main/webapp/app/shared/delete-dialog/delete-dialog.component.html b/src/main/webapp/app/shared/delete-dialog/delete-dialog.component.html index eaadd2bb1acd..47e8a7fd5b6c 100644 --- a/src/main/webapp/app/shared/delete-dialog/delete-dialog.component.html +++ b/src/main/webapp/app/shared/delete-dialog/delete-dialog.component.html @@ -29,6 +29,22 @@