From 7cad252d1abb6727740cbcfb15659739b384a487 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Mon, 25 Nov 2024 16:42:49 +0100 Subject: [PATCH 01/15] Quiz exercises: Show all participations with filter options --- .../StudentParticipationRepository.java | 175 ++++++++++-------- .../exercise/web/ParticipationResource.java | 5 +- .../ProgrammingSubmissionRepository.java | 10 +- ...grammingExerciseParticipationResource.java | 4 +- .../participation.component.html | 50 +++-- .../ParticipationIntegrationTest.java | 12 +- 6 files changed, 137 insertions(+), 119 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java index 1101f94d4708..bb775298efb5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java @@ -59,7 +59,8 @@ public interface StudentParticipationRepository extends ArtemisJpaRepository findByCourseIdWithEagerRatedResults(@Param("courseId") long courseId); @@ -69,8 +70,10 @@ public interface StudentParticipationRepository extends ArtemisJpaRepository findByCourseIdAndStudentIdWithEagerRatedResults(@Param("courseId") long courseId, @Param("studentId") long studentId); @@ -79,7 +82,8 @@ SELECT COUNT(p.id) > 0 FROM StudentParticipation p LEFT JOIN p.team.students ts WHERE p.exercise.course.id = :courseId - AND (p.student.id = :studentId OR ts.id = :studentId) + AND (p.student.id = :studentId + OR ts.id = :studentId) """) boolean existsByCourseIdAndStudentId(@Param("courseId") long courseId, @Param("studentId") long studentId); @@ -91,7 +95,8 @@ SELECT COUNT(p.id) > 0 WHERE p.testRun = FALSE AND p.exercise.exerciseGroup.exam.id = :examId AND r.rated = TRUE - AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) + AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s.type IS NULL) """) List findByExamIdWithEagerLegalSubmissionsRatedResults(@Param("examId") long examId); @@ -125,7 +130,8 @@ SELECT COUNT(p.id) > 0 LEFT JOIN FETCH p.submissions s WHERE p.exercise.id = :exerciseId AND p.student.login = :username - AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) + AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s.type IS NULL) """) Optional findWithEagerLegalSubmissionsByExerciseIdAndStudentLogin(@Param("exerciseId") long exerciseId, @Param("username") String username); @@ -135,7 +141,8 @@ SELECT COUNT(p.id) > 0 LEFT JOIN FETCH p.submissions s WHERE p.exercise.id = :exerciseId AND p.student.login = :username - AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) + AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s.type IS NULL) AND p.testRun = :testRun """) Optional findWithEagerLegalSubmissionsByExerciseIdAndStudentLoginAndTestRun(@Param("exerciseId") long exerciseId, @Param("username") String username, @@ -157,7 +164,8 @@ Optional findWithEagerLegalSubmissionsByExerciseIdAndStude LEFT JOIN FETCH t.students WHERE p.exercise.id = :exerciseId AND p.team.id = :teamId - AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) + AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s.type IS NULL) """) Optional findWithEagerLegalSubmissionsAndTeamStudentsByExerciseIdAndTeamId(@Param("exerciseId") long exerciseId, @Param("teamId") long teamId); @@ -175,8 +183,9 @@ SELECT COUNT(p) > 0 FROM StudentParticipation p LEFT JOIN p.team.students u LEFT JOIN p.student s - WHERE p.id = :participationId AND - (s.login = :login OR u.login = :login) + WHERE p.id = :participationId + AND (s.login = :login + OR u.login = :login) """) boolean existsByIdAndParticipatingStudentLogin(@Param("participationId") long participationId, @Param("login") String login); @@ -187,7 +196,8 @@ SELECT COUNT(p) > 0 LEFT JOIN FETCH s.results WHERE p.exercise.id = :exerciseId AND p.testRun = :testRun - AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) + AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s.type IS NULL) """) List findByExerciseIdAndTestRunWithEagerLegalSubmissionsResult(@Param("exerciseId") long exerciseId, @Param("testRun") boolean testRun); @@ -235,7 +245,10 @@ Optional findByExerciseIdAndStudentIdAndTestRunWithEagerSu LEFT JOIN FETCH r.assessmentNote WHERE p.exercise.id = :exerciseId AND ( - r.id = (SELECT MAX(p_r.id) FROM p.results p_r) + r.id = ( + SELECT MAX(p_r.id) + FROM p.results p_r + ) OR r.assessmentType <> de.tum.cit.aet.artemis.assessment.domain.AssessmentType.AUTOMATIC OR r IS NULL ) @@ -260,29 +273,16 @@ Optional findByExerciseIdAndStudentIdAndTestRunWithEagerSu LEFT JOIN FETCH t.students WHERE p.exercise.id = :exerciseId AND ( - r.id = (SELECT MAX(p_r.id) FROM p.results p_r) + r.id = ( + SELECT MAX(p_r.id) + FROM p.results p_r + ) OR r.assessmentType <> de.tum.cit.aet.artemis.assessment.domain.AssessmentType.AUTOMATIC OR r IS NULL ) """) Set findByExerciseIdWithLatestAndManualResultsWithTeamInformation(@Param("exerciseId") long exerciseId); - @Query(""" - SELECT DISTINCT p - FROM StudentParticipation p - LEFT JOIN FETCH p.results r - LEFT JOIN FETCH r.submission s - LEFT JOIN FETCH p.submissions - LEFT JOIN FETCH r.assessmentNote - WHERE p.exercise.id = :exerciseId - AND ( - r.id = (SELECT MAX(p_r.id) FROM p.results p_r WHERE p_r.rated = TRUE) - OR r.assessmentType <> de.tum.cit.aet.artemis.assessment.domain.AssessmentType.AUTOMATIC - OR r IS NULL - ) - """) - Set findByExerciseIdWithLatestAndManualRatedResultsAndAssessmentNote(@Param("exerciseId") long exerciseId); - @Query(""" SELECT DISTINCT p FROM StudentParticipation p @@ -292,7 +292,11 @@ Optional findByExerciseIdAndStudentIdAndTestRunWithEagerSu AND p.testRun = :testRun AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) AND r.assessmentType <> de.tum.cit.aet.artemis.assessment.domain.AssessmentType.AUTOMATIC - AND r.id = (SELECT MAX(r2.id) FROM p.results r2 WHERE r2.completionDate IS NOT NULL) + AND r.id = ( + SELECT MAX(r2.id) + FROM p.results r2 + WHERE r2.completionDate IS NOT NULL + ) """) Set findByExerciseIdAndTestRunWithEagerLegalSubmissionsAndLatestResultWithCompletionDate(@Param("exerciseId") long exerciseId, @Param("testRun") boolean testRun); @@ -343,16 +347,14 @@ default List findByExerciseIdWithLatestAutomaticResultAndF LEFT JOIN FETCH f.testCase LEFT JOIN FETCH r.submission s WHERE p.id = :participationId - AND (r.id = ( + AND r.id = ( SELECT MAX(pr.id) FROM p.results pr LEFT JOIN pr.submission prs WHERE pr.assessmentType = de.tum.cit.aet.artemis.assessment.domain.AssessmentType.AUTOMATIC - AND ( - prs.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL - OR prs.type IS NULL - ) - )) + AND (prs.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR prs.type IS NULL) + ) """) Optional findByIdWithLatestAutomaticResultAndFeedbacksAndTestCases(@Param("participationId") long participationId); @@ -366,10 +368,8 @@ SELECT MAX(pr.id) LEFT JOIN FETCH r.submission s WHERE p.exercise.id = :exerciseId AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) - AND ( - r.assessmentType = de.tum.cit.aet.artemis.assessment.domain.AssessmentType.MANUAL - OR r.assessmentType = de.tum.cit.aet.artemis.assessment.domain.AssessmentType.SEMI_AUTOMATIC - ) + AND (r.assessmentType = de.tum.cit.aet.artemis.assessment.domain.AssessmentType.MANUAL + OR r.assessmentType = de.tum.cit.aet.artemis.assessment.domain.AssessmentType.SEMI_AUTOMATIC) """) List findByExerciseIdWithManualResultAndFeedbacksAndTestCases(@Param("exerciseId") long exerciseId); @@ -385,11 +385,10 @@ default List findByExerciseIdWithManualResultAndFeedbacksA LEFT JOIN FETCH f.testCase LEFT JOIN FETCH r.submission s WHERE p.id = :participationId - AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) - AND ( - r.assessmentType = de.tum.cit.aet.artemis.assessment.domain.AssessmentType.MANUAL - OR r.assessmentType = de.tum.cit.aet.artemis.assessment.domain.AssessmentType.SEMI_AUTOMATIC - ) + AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s.type IS NULL) + AND (r.assessmentType = de.tum.cit.aet.artemis.assessment.domain.AssessmentType.MANUAL + OR r.assessmentType = de.tum.cit.aet.artemis.assessment.domain.AssessmentType.SEMI_AUTOMATIC) """) Optional findByIdWithManualResultAndFeedbacks(@Param("participationId") long participationId); @@ -399,7 +398,8 @@ default List findByExerciseIdWithManualResultAndFeedbacksA LEFT JOIN FETCH p.submissions s WHERE p.exercise.id = :exerciseId AND p.student.id = :studentId - AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) + AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s.type IS NULL) """) List findByExerciseIdAndStudentIdWithEagerLegalSubmissions(@Param("exerciseId") long exerciseId, @Param("studentId") long studentId); @@ -427,7 +427,8 @@ default List findByExerciseIdWithManualResultAndFeedbacksA LEFT JOIN FETCH p.submissions s WHERE p.exercise.id = :exerciseId AND p.team.id = :teamId - AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) + AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s.type IS NULL) """) List findByExerciseIdAndTeamIdWithEagerLegalSubmissions(@Param("exerciseId") long exerciseId, @Param("teamId") long teamId); @@ -451,8 +452,10 @@ default List findByExerciseIdWithManualResultAndFeedbacksA LEFT JOIN FETCH t.students WHERE p.exercise.id = :exerciseId AND p.team.id = :teamId - AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) - AND (rs.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR rs.type IS NULL) + AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s.type IS NULL) + AND (rs.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR rs.type IS NULL) """) List findByExerciseIdAndTeamIdWithEagerResultsAndLegalSubmissionsAndTeamStudents(@Param("exerciseId") long exerciseId, @Param("teamId") long teamId); @@ -471,7 +474,8 @@ SELECT MAX(pr.id) LEFT JOIN pr.submission prs WHERE prs.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR prs.type IS NULL - ) OR r.id IS NULL) + ) + OR r.id IS NULL) """) Optional findByExerciseIdAndStudentIdAndTestRunWithLatestResult(@Param("exerciseId") long exerciseId, @Param("studentId") long studentId, @Param("testRun") boolean testRun); @@ -524,7 +528,10 @@ WHERE prs.assessmentType IN ( ) ) AND submission.submitted = TRUE - AND submission.id = (SELECT MAX(s.id) FROM p.submissions s) + AND submission.id = ( + SELECT MAX(s.id) + FROM p.submissions s + ) """) List findByExerciseIdWithLatestSubmissionWithoutManualResultsAndIgnoreTestRunParticipation(@Param("exerciseId") long exerciseId, @Param("correctionRound") long correctionRound); @@ -548,7 +555,10 @@ WHERE prs.assessmentType IN ( de.tum.cit.aet.artemis.assessment.domain.AssessmentType.SEMI_AUTOMATIC ) ) AND s.submitted = TRUE - AND s.id = (SELECT MAX(s.id) FROM p.submissions s) + AND s.id = ( + SELECT MAX(s.id) + FROM p.submissions s + ) """) List findByExerciseIdWithLatestSubmissionWithoutManualResultsWithPassedIndividualDueDateIgnoreTestRuns(@Param("exerciseId") long exerciseId, @Param("now") ZonedDateTime now); @@ -558,7 +568,8 @@ List findByExerciseIdWithLatestSubmissionWithoutManualResu FROM Participation p LEFT JOIN FETCH p.submissions s WHERE p.id = :participationId - AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) + AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s.type IS NULL) """) Optional findWithEagerLegalSubmissionsById(@Param("participationId") long participationId); @@ -593,8 +604,10 @@ List findByExerciseIdWithLatestSubmissionWithoutManualResu LEFT JOIN FETCH p.team t LEFT JOIN FETCH t.students WHERE p.id = :participationId - AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) - AND (rs.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR rs.type IS NULL) + AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s.type IS NULL) + AND (rs.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR rs.type IS NULL) """) Optional findWithEagerLegalSubmissionsResultsFeedbacksById(@Param("participationId") long participationId); @@ -617,10 +630,9 @@ List findByExerciseIdWithLatestSubmissionWithoutManualResu FROM StudentParticipation p JOIN Result r ON r.participation.id = p.id WHERE p.exercise.id = :exerciseId - AND ( - p.student.firstName LIKE %:partialStudentName% - OR p.student.lastName LIKE %:partialStudentName% - ) AND r.completionDate IS NOT NULL + AND (p.student.firstName LIKE %:partialStudentName% + OR p.student.lastName LIKE %:partialStudentName%) + AND r.completionDate IS NOT NULL """) List findIdsByExerciseIdAndStudentName(@Param("exerciseId") long exerciseId, @Param("partialStudentName") String partialStudentName, Pageable pageable); @@ -632,10 +644,9 @@ SELECT COUNT(p) FROM StudentParticipation p JOIN Result r ON r.participation.id = p.id WHERE p.exercise.id = :exerciseId - AND ( - p.student.firstName LIKE %:partialStudentName% - OR p.student.lastName LIKE %:partialStudentName% - ) AND r.completionDate IS NOT NULL + AND (p.student.firstName LIKE %:partialStudentName% + OR p.student.lastName LIKE %:partialStudentName%) + AND r.completionDate IS NOT NULL """) long countByExerciseIdAndStudentName(@Param("exerciseId") long exerciseId, @Param("partialStudentName") String partialStudentName); @@ -666,8 +677,10 @@ default Page findAllWithEagerSubmissionsAndResultsByExerci LEFT JOIN FETCH p.submissions s LEFT JOIN FETCH s.results sr WHERE p.exercise.id = :exerciseId - AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) - AND (rs.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR rs.type IS NULL) + AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s.type IS NULL) + AND (rs.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR rs.type IS NULL) """) List findAllWithEagerLegalSubmissionsAndEagerResultsByExerciseId(@Param("exerciseId") long exerciseId); @@ -697,12 +710,17 @@ default Page findAllWithEagerSubmissionsAndResultsByExerci LEFT JOIN FETCH p.team WHERE p.exercise.id = :exerciseId AND p.testRun = FALSE - AND s.id = (SELECT MAX(s2.id) - FROM p.submissions s2 - WHERE s2.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s2.type IS NULL) - AND r.id = (SELECT MAX(r2.id) - FROM s.results r2 - WHERE r2.rated = TRUE) + AND s.id = ( + SELECT MAX(s2.id) + FROM p.submissions s2 + WHERE s2.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s2.type IS NULL + ) + AND r.id = ( + SELECT MAX(r2.id) + FROM s.results r2 + WHERE r2.rated = TRUE + ) """) List findAllForPlagiarism(@Param("exerciseId") long exerciseId); @@ -713,7 +731,8 @@ default Page findAllWithEagerSubmissionsAndResultsByExerci LEFT JOIN FETCH s.results r WHERE p.student.id = :studentId AND p.exercise IN :exercises - AND (p.testRun = FALSE OR :includeTestRuns = TRUE) + AND (p.testRun = FALSE + OR :includeTestRuns = TRUE) """) Set findByStudentIdAndIndividualExercisesWithEagerSubmissionsResult(@Param("studentId") long studentId, @Param("exercises") Collection exercises, @Param("includeTestRuns") boolean includeTestRuns); @@ -786,7 +805,8 @@ List findTestRunParticipationsByStudentIdAndIndividualExer LEFT JOIN FETCH t.students teamStudent WHERE teamStudent.id = :studentId AND p.exercise IN :exercises - AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) + AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s.type IS NULL) """) Set findByStudentIdAndTeamExercisesWithEagerLegalSubmissionsResult(@Param("studentId") long studentId, @Param("exercises") Collection exercises); @@ -799,7 +819,8 @@ Set findByStudentIdAndTeamExercisesWithEagerLegalSubmissio LEFT JOIN FETCH p.team t WHERE p.exercise.course.id = :courseId AND t.shortName = :teamShortName - AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) + AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s.type IS NULL) """) List findAllByCourseIdAndTeamShortNameWithEagerLegalSubmissionsResult(@Param("courseId") long courseId, @Param("teamShortName") String teamShortName); @@ -831,7 +852,8 @@ SELECT p.id, COUNT(s) LEFT JOIN p.submissions s WHERE p.team.shortName = :teamShortName AND p.exercise.course.id = :courseId - AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s.type IS NULL) + AND (s.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL + OR s.type IS NULL) GROUP BY p.id """) List countLegalSubmissionsPerParticipationByCourseIdAndTeamShortName(@Param("courseId") long courseId, @Param("teamShortName") String teamShortName); @@ -850,7 +872,8 @@ AND EXISTS ( FROM p.submissions s1 WHERE s1.participation.id = p.id AND s1.submitted = TRUE - AND (r.assessor = :assessor OR r.assessor.id IS NULL) + AND (r.assessor = :assessor + OR r.assessor.id IS NULL) ) """) List findAllByParticipationExerciseIdAndResultAssessorAndCorrectionRoundIgnoreTestRuns(@Param("exerciseId") long exerciseId, diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java index 4d549bb3fe66..b56b2df2aed9 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java @@ -66,7 +66,6 @@ import de.tum.cit.aet.artemis.core.service.messaging.InstanceMessageSendService; import de.tum.cit.aet.artemis.core.util.HeaderUtil; 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.domain.InitializationState; import de.tum.cit.aet.artemis.exercise.domain.Submission; import de.tum.cit.aet.artemis.exercise.domain.SubmissionType; @@ -595,9 +594,7 @@ public ResponseEntity> updateParticipationDueDates(@P } private Set findParticipationWithLatestResults(Exercise exercise) { - if (exercise.getExerciseType() == ExerciseType.QUIZ) { - return studentParticipationRepository.findByExerciseIdWithLatestAndManualRatedResultsAndAssessmentNote(exercise.getId()); - } + // TODO: we should reduce the amount of data fetched here and sent to the client, because submissions and results are not used at all if (exercise.isTeamMode()) { // For team exercises the students need to be eagerly fetched return studentParticipationRepository.findByExerciseIdWithLatestAndManualResultsWithTeamInformation(exercise.getId()); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingSubmissionRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingSubmissionRepository.java index ffe438217b64..90d514ca4b68 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingSubmissionRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingSubmissionRepository.java @@ -50,7 +50,8 @@ default ProgrammingSubmission findFirstByParticipationIdAndCommitHashOrderByIdDe @Query(value = """ SELECT new de.tum.cit.aet.artemis.programming.dto.ProgrammingSubmissionIdAndSubmissionDateDTO(ps.id, ps.submissionDate) FROM ProgrammingSubmission ps - WHERE ps.participation.id = :participationId ORDER BY ps.submissionDate DESC + WHERE ps.participation.id = :participationId + ORDER BY ps.submissionDate DESC """) List findFirstIdByParticipationIdOrderBySubmissionDateDesc(@Param("participationId") long participationId, Pageable pageable); @@ -72,8 +73,8 @@ default Optional findFirstByParticipationIdWithResultsOrd if (result.isEmpty()) { return Optional.empty(); } - long id = result.getFirst().programmingSubmissionId(); - return findProgrammingSubmissionWithResultsById(id); + long submissionId = result.getFirst().programmingSubmissionId(); + return findProgrammingSubmissionWithResultsById(submissionId); } @Query(""" @@ -104,8 +105,7 @@ default Optional findFirstByParticipationIdWithResultsOrd * @return ProgrammingSubmission list (can be empty!) */ default List findGradedByParticipationIdWithResultsOrderBySubmissionDateDesc(long participationId, Pageable pageable) { - List ids = findSubmissionIdsAndDatesByParticipationId(participationId, pageable).stream().map(ProgrammingSubmissionIdAndSubmissionDateDTO::programmingSubmissionId) - .toList(); + var ids = findSubmissionIdsAndDatesByParticipationId(participationId, pageable).stream().map(ProgrammingSubmissionIdAndSubmissionDateDTO::programmingSubmissionId).toList(); if (ids.isEmpty()) { return Collections.emptyList(); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java index be1c99c67be6..debe78f111da 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java @@ -231,12 +231,12 @@ public ResponseEntity getLatestPendingSubmission(@PathVar @GetMapping("programming-exercises/{exerciseId}/latest-pending-submissions") @EnforceAtLeastTutor public ResponseEntity>> getLatestPendingSubmissionsByExerciseId(@PathVariable Long exerciseId) { - ProgrammingExercise programmingExercise; - programmingExercise = programmingExerciseRepository.findByIdWithTemplateAndSolutionParticipationElseThrow(exerciseId); + ProgrammingExercise programmingExercise = programmingExerciseRepository.findByIdWithTemplateAndSolutionParticipationElseThrow(exerciseId); if (!authCheckService.isAtLeastTeachingAssistantForExercise(programmingExercise)) { throw new AccessForbiddenException("exercise", exerciseId); } + // TODO: this REST call is quite slow for > 100 participations. We should consider a more efficient way to get the latest pending submissions. Map> pendingSubmissions = submissionService.getLatestPendingSubmissionsForProgrammingExercise(exerciseId); // Remove unnecessary data to make response smaller (exercise, student of participation). pendingSubmissions = pendingSubmissions.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, entry -> { diff --git a/src/main/webapp/app/exercises/shared/participation/participation.component.html b/src/main/webapp/app/exercises/shared/participation/participation.component.html index c3eadf5cd3e1..27f175995eef 100644 --- a/src/main/webapp/app/exercises/shared/participation/participation.component.html +++ b/src/main/webapp/app/exercises/shared/participation/participation.component.html @@ -4,38 +4,36 @@

{{ exercise?.title }} - {{ filteredParticipationsSize }}

- @if (exercise?.type === ExerciseType.PROGRAMMING) { -
- - +
+ + + + @if (exercise.type === ExerciseType.PROGRAMMING && afterDueDate) { - @if (exercise.type === ExerciseType.PROGRAMMING && afterDueDate) { - - } -
- } + } +
@if (exercise?.type !== ExerciseType.QUIZ && exercise?.isAtLeastInstructor) {
diff --git a/src/test/java/de/tum/cit/aet/artemis/exercise/participation/ParticipationIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/exercise/participation/ParticipationIntegrationTest.java index 8e97d56893d8..3f32fb13ca5d 100644 --- a/src/test/java/de/tum/cit/aet/artemis/exercise/participation/ParticipationIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/exercise/participation/ParticipationIntegrationTest.java @@ -916,7 +916,8 @@ void getAllParticipationsForExercise_withLatestResults_forQuizExercise() throws courseRepository.save(course); exerciseRepository.save(quizExercise); - var participation = participationUtilService.createAndSaveParticipationForExercise(quizExercise, TEST_PREFIX + "student1"); + final var login = TEST_PREFIX + "student1"; + var participation = participationUtilService.createAndSaveParticipationForExercise(quizExercise, login); var result1 = participationUtilService.createSubmissionAndResult(participation, 42, true); var notGradedResult = participationUtilService.addResultToParticipation(participation, result1.getSubmission()); notGradedResult.setRated(false); @@ -926,11 +927,10 @@ void getAllParticipationsForExercise_withLatestResults_forQuizExercise() throws params.add("withLatestResults", "true"); var participations = request.getList("/api/exercises/" + quizExercise.getId() + "/participations", HttpStatus.OK, StudentParticipation.class, params); - var receivedParticipationWithResult = participations.stream().filter(p -> ((User) p.getParticipant()).getLogin().equals(TEST_PREFIX + "student1")).findFirst() - .orElseThrow(); - assertThat(receivedParticipationWithResult.getResults()).containsOnly(result1); - assertThat(receivedParticipationWithResult.getSubmissions()).isEmpty(); - assertThat(receivedParticipationWithResult.getSubmissionCount()).isEqualTo(1); + var receivedParticipation = participations.stream().filter(p -> p.getParticipantIdentifier().equals(login)).findFirst().orElseThrow(); + assertThat(receivedParticipation.getResults()).containsOnly(notGradedResult); + assertThat(receivedParticipation.getSubmissions()).isEmpty(); + assertThat(receivedParticipation.getSubmissionCount()).isEqualTo(1); } @Test From a4c674ca5b41b75ec3144969e373522787fedcde Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Mon, 25 Nov 2024 17:49:22 +0100 Subject: [PATCH 02/15] General: Improve performance of exercise participations view --- .../exercise/web/ParticipationResource.java | 2 +- ...xerciseStudentParticipationRepository.java | 4 ++-- .../service/ProgrammingSubmissionService.java | 19 +++++++++++-------- ...grammingExerciseParticipationResource.java | 9 +++++---- .../participation/participation.component.ts | 2 +- .../participation.component.spec.ts | 4 ++-- 6 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java index b56b2df2aed9..05e3166ff171 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java @@ -594,7 +594,7 @@ public ResponseEntity> updateParticipationDueDates(@P } private Set findParticipationWithLatestResults(Exercise exercise) { - // TODO: we should reduce the amount of data fetched here and sent to the client, because submissions and results are not used at all + // TODO: we should reduce the amount of data fetched here and sent to the client: double check which data is actually required in the exercise scores page if (exercise.isTeamMode()) { // For team exercises the students need to be eagerly fetched return studentParticipationRepository.findByExerciseIdWithLatestAndManualResultsWithTeamInformation(exercise.getId()); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java index cc1f57c533fa..c88024f0835b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java @@ -136,8 +136,8 @@ Optional findWithSubmissionsAndEagerStu @EntityGraph(type = LOAD, attributePaths = { "submissions", "team.students" }) List findWithSubmissionsById(long participationId); - @EntityGraph(type = LOAD, attributePaths = { "submissions" }) - List findWithSubmissionsByExerciseId(long exerciseId); + @EntityGraph(type = LOAD, attributePaths = { "submissions.results" }) + List findWithSubmissionsAndResultsByExerciseId(long exerciseId); @EntityGraph(type = LOAD, attributePaths = { "submissions", "team.students" }) List findWithSubmissionsAndTeamStudentsByExerciseId(long exerciseId); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingSubmissionService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingSubmissionService.java index 58665d8beae4..9de4485f16b2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingSubmissionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingSubmissionService.java @@ -6,6 +6,7 @@ import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -341,14 +342,16 @@ public Optional getLatestPendingSubmission(Long participa * @return a Map of {[participationId]: ProgrammingSubmission | null}. Will contain an entry for every student participation of the exercise and a submission object if a * pending submission exists or null if not. */ - public Map> getLatestPendingSubmissionsForProgrammingExercise(Long programmingExerciseId) { - List participations = programmingExerciseStudentParticipationRepository.findWithSubmissionsByExerciseId(programmingExerciseId); - // TODO: find the latest pending submission directly using Java (the submissions are available now) and not with additional db queries - return participations.stream().collect(Collectors.toMap(Participation::getId, p -> findLatestPendingSubmissionForParticipation(p.getId()))); - } - - private Optional findLatestPendingSubmissionForParticipation(final long participationId) { - return findLatestPendingSubmissionForParticipation(participationId, false); + public Map> getLatestPendingSubmissionsForProgrammingExercise(Long programmingExerciseId) { + var participations = programmingExerciseStudentParticipationRepository.findWithSubmissionsAndResultsByExerciseId(programmingExerciseId); + return participations.stream().collect(Collectors.toMap(Participation::getId, p -> { + var latestSubmission = p.getSubmissions().stream().max(Comparator.comparing(Submission::getSubmissionDate)); + if (latestSubmission.isEmpty() || latestSubmission.get().getLatestResult() != null) { + // This is not an error case, it is very likely that there is no pending submission for a participation. + return Optional.empty(); + } + return latestSubmission; + })); } private Optional findLatestPendingSubmissionForParticipation(final long participationId, final boolean isGraded) { diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java index debe78f111da..2566e1a87fb8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java @@ -37,6 +37,7 @@ import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.exam.repository.StudentExamRepository; import de.tum.cit.aet.artemis.exam.service.ExamService; +import de.tum.cit.aet.artemis.exercise.domain.Submission; import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; import de.tum.cit.aet.artemis.exercise.repository.ParticipationRepository; import de.tum.cit.aet.artemis.exercise.service.ParticipationAuthorizationCheckService; @@ -230,17 +231,17 @@ public ResponseEntity getLatestPendingSubmission(@PathVar */ @GetMapping("programming-exercises/{exerciseId}/latest-pending-submissions") @EnforceAtLeastTutor - public ResponseEntity>> getLatestPendingSubmissionsByExerciseId(@PathVariable Long exerciseId) { + public ResponseEntity>> getLatestPendingSubmissionsByExerciseId(@PathVariable Long exerciseId) { ProgrammingExercise programmingExercise = programmingExerciseRepository.findByIdWithTemplateAndSolutionParticipationElseThrow(exerciseId); if (!authCheckService.isAtLeastTeachingAssistantForExercise(programmingExercise)) { throw new AccessForbiddenException("exercise", exerciseId); } - // TODO: this REST call is quite slow for > 100 participations. We should consider a more efficient way to get the latest pending submissions. - Map> pendingSubmissions = submissionService.getLatestPendingSubmissionsForProgrammingExercise(exerciseId); + // TODO: use a different data structure than map here + Map> pendingSubmissions = submissionService.getLatestPendingSubmissionsForProgrammingExercise(exerciseId); // Remove unnecessary data to make response smaller (exercise, student of participation). pendingSubmissions = pendingSubmissions.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, entry -> { - Optional submissionOpt = entry.getValue(); + Optional submissionOpt = entry.getValue(); // Remove participation, is not needed in the response. submissionOpt.ifPresent(submission -> submission.setParticipation(null)); return submissionOpt; diff --git a/src/main/webapp/app/exercises/shared/participation/participation.component.ts b/src/main/webapp/app/exercises/shared/participation/participation.component.ts index 627a33ce8815..c3e018e4b526 100644 --- a/src/main/webapp/app/exercises/shared/participation/participation.component.ts +++ b/src/main/webapp/app/exercises/shared/participation/participation.component.ts @@ -150,7 +150,7 @@ export class ParticipationComponent implements OnInit, OnDestroy { } private loadParticipations(exerciseId: number) { - this.participationService.findAllParticipationsByExercise(exerciseId, true).subscribe((participationsResponse) => { + this.participationService.findAllParticipationsByExercise(exerciseId, false).subscribe((participationsResponse) => { this.participations = participationsResponse.body!; if (this.exercise.type === ExerciseType.PROGRAMMING) { const programmingExercise = this.exercise as ProgrammingExercise; diff --git a/src/test/javascript/spec/component/participation/participation.component.spec.ts b/src/test/javascript/spec/component/participation/participation.component.spec.ts index 6661b643ed31..62f43f1f2013 100644 --- a/src/test/javascript/spec/component/participation/participation.component.spec.ts +++ b/src/test/javascript/spec/component/participation/participation.component.spec.ts @@ -125,7 +125,7 @@ describe('ParticipationComponent', () => { expect(exerciseFindStub).toHaveBeenCalledOnce(); expect(exerciseFindStub).toHaveBeenCalledWith(theExercise.id); expect(participationFindStub).toHaveBeenCalledOnce(); - expect(participationFindStub).toHaveBeenCalledWith(theExercise.id, true); + expect(participationFindStub).toHaveBeenCalledWith(theExercise.id, false); })); it('should initialize for programming exercise', fakeAsync(() => { @@ -154,7 +154,7 @@ describe('ParticipationComponent', () => { expect(exerciseFindStub).toHaveBeenCalledOnce(); expect(exerciseFindStub).toHaveBeenCalledWith(theExercise.id); expect(participationFindStub).toHaveBeenCalledOnce(); - expect(participationFindStub).toHaveBeenCalledWith(theExercise.id, true); + expect(participationFindStub).toHaveBeenCalledWith(theExercise.id, false); expect(submissionGetStateStub).toHaveBeenCalledOnce(); expect(submissionGetStateStub).toHaveBeenCalledWith(theExercise.id); })); From d2a82ea060e88b6acb046de86539a4c4add9f2ac Mon Sep 17 00:00:00 2001 From: Asli Aykan <56061820+asliayk@users.noreply.github.com> Date: Mon, 25 Nov 2024 21:24:17 +0100 Subject: [PATCH 03/15] Communication: Fix visibility of the edit message option for non-authors (#9830) --- .../answer-post/answer-post.component.html | 7 ++- .../answer-post/answer-post.component.ts | 11 ++-- .../app/shared/metis/post/post.component.html | 7 ++- .../app/shared/metis/post/post.component.ts | 11 ++-- .../answer-post-reactions-bar.component.html | 5 +- .../answer-post-reactions-bar.component.ts | 25 +++++++--- .../post-reactions-bar.component.html | 4 +- .../post-reactions-bar.component.ts | 27 ++++++---- ...nswer-post-reactions-bar.component.spec.ts | 23 ++++++++- .../post-reactions-bar.component.spec.ts | 50 +++++++++++++++++-- 10 files changed, 132 insertions(+), 38 deletions(-) diff --git a/src/main/webapp/app/shared/metis/answer-post/answer-post.component.html b/src/main/webapp/app/shared/metis/answer-post/answer-post.component.html index 6682f5c146c0..1579e97f9a0f 100644 --- a/src/main/webapp/app/shared/metis/answer-post/answer-post.component.html +++ b/src/main/webapp/app/shared/metis/answer-post/answer-post.component.html @@ -31,7 +31,8 @@ [isThreadSidebar]="isThreadSidebar" (openPostingCreateEditModal)="createAnswerPostModal.open()" (reactionsUpdated)="onReactionsUpdated($event)" - (mayEditOrDeleteOutput)="onMayEditOrDelete($event)" + (mayDeleteOutput)="onMayDelete($event)" + (mayEditOutput)="onMayEdit($event)" (isDeleteEvent)="onDeleteEvent(true)" />
@@ -63,11 +64,13 @@ - @if (mayEditOrDelete) { + @if (mayEdit) { + } + @if (mayDelete) { } - @if (mayEditOrDelete) { + @if (mayEdit) { + } + @if (mayDelete) { } - @if (mayEditOrDelete) { + @if (mayEdit) { - + } + @if (mayDelete) { + + + diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component.ts new file mode 100644 index 000000000000..4a31192b6299 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component.ts @@ -0,0 +1,23 @@ +import { Component, inject, input } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; + +@Component({ + selector: 'jhi-confirm-feedback-channel-creation-modal', + templateUrl: './confirm-feedback-channel-creation-modal.component.html', + imports: [ArtemisSharedCommonModule], + standalone: true, +}) +export class ConfirmFeedbackChannelCreationModalComponent { + protected readonly TRANSLATION_BASE = 'artemisApp.programmingExercise.configureGrading.feedbackAnalysis.feedbackDetailChannel.confirmationModal'; + affectedStudentsCount = input.required(); + private activeModal = inject(NgbActiveModal); + + confirm(): void { + this.activeModal.close(true); + } + + dismiss(): void { + this.activeModal.dismiss(); + } +} diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.html b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.html new file mode 100644 index 000000000000..36d5b08ed5b9 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.html @@ -0,0 +1,87 @@ + + diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.ts new file mode 100644 index 000000000000..e9b7963f0e78 --- /dev/null +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component.ts @@ -0,0 +1,71 @@ +import { Component, inject, input, output, signal } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ChannelDTO } from 'app/entities/metis/conversation/channel.model'; +import { FeedbackDetail } from 'app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.service'; +import { ConfirmFeedbackChannelCreationModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { AlertService } from 'app/core/util/alert.service'; + +@Component({ + selector: 'jhi-feedback-detail-channel-modal', + templateUrl: './feedback-detail-channel-modal.component.html', + imports: [ArtemisSharedCommonModule], + standalone: true, +}) +export class FeedbackDetailChannelModalComponent { + protected readonly TRANSLATION_BASE = 'artemisApp.programmingExercise.configureGrading.feedbackAnalysis.feedbackDetailChannel'; + affectedStudentsCount = input.required(); + feedbackDetail = input.required(); + formSubmitted = output<{ channelDto: ChannelDTO; navigate: boolean }>(); + + isConfirmModalOpen = signal(false); + + private alertService = inject(AlertService); + private readonly formBuilder = inject(FormBuilder); + private readonly activeModal = inject(NgbActiveModal); + private readonly modalService = inject(NgbModal); + form: FormGroup = this.formBuilder.group({ + name: ['', [Validators.required, Validators.maxLength(30), Validators.pattern('^[a-z0-9-]{1}[a-z0-9-]{0,30}$')]], + description: ['', [Validators.required, Validators.maxLength(250)]], + isPublic: [true, Validators.required], + isAnnouncementChannel: [false, Validators.required], + }); + + async submitForm(navigate: boolean): Promise { + if (this.form.valid && !this.isConfirmModalOpen()) { + this.isConfirmModalOpen.set(true); + const result = await this.handleModal(); + if (result) { + const channelDTO = new ChannelDTO(); + channelDTO.name = this.form.get('name')?.value; + channelDTO.description = this.form.get('description')?.value; + channelDTO.isPublic = this.form.get('isPublic')?.value; + channelDTO.isAnnouncementChannel = this.form.get('isAnnouncementChannel')?.value; + + this.formSubmitted.emit({ channelDto: channelDTO, navigate }); + this.closeModal(); + } + this.isConfirmModalOpen.set(false); + } + } + + async handleModal(): Promise { + try { + const modalRef = this.modalService.open(ConfirmFeedbackChannelCreationModalComponent, { centered: true }); + modalRef.componentInstance.affectedStudentsCount = this.affectedStudentsCount; + return await modalRef.result; + } catch (error) { + this.alertService.error(error); + return false; + } + } + + closeModal(): void { + this.activeModal.close(); + } + + dismissModal(): void { + this.activeModal.dismiss(); + } +} diff --git a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html index 20206a2c4ae3..2c1d01040253 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html +++ b/src/main/webapp/app/exercises/programming/manage/grading/feedback-analysis/feedback-analysis.component.html @@ -60,8 +60,11 @@

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

}
- @if (programmingExercise.isAtLeastEditor && activeTab === 'feedback-analysis') { - + @if (programmingExercise.isAtLeastEditor && activeTab === 'feedback-analysis' && programmingExercise.title && programmingExercise.id && course.id) { + }
} diff --git a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts index c5ef675338f5..13ce8237d382 100644 --- a/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts +++ b/src/main/webapp/app/exercises/programming/manage/grading/programming-exercise-configure-grading.component.ts @@ -6,7 +6,7 @@ import { TranslateService } from '@ngx-translate/core'; import { AccountService } from 'app/core/auth/account.service'; import { AlertService } from 'app/core/util/alert.service'; import { CourseManagementService } from 'app/course/manage/course-management.service'; -import { Course } from 'app/entities/course.model'; +import { Course, isCommunicationEnabled } from 'app/entities/course.model'; import { IssuesMap, ProgrammingExerciseGradingStatistics } from 'app/entities/programming/programming-exercise-test-case-statistics.model'; import { ProgrammingExerciseTestCase, Visibility } from 'app/entities/programming/programming-exercise-test-case.model'; import { ProgrammingExercise, ProgrammingLanguage } from 'app/entities/programming/programming-exercise.model'; @@ -92,6 +92,7 @@ export class ProgrammingExerciseConfigureGradingComponent implements OnInit, OnD readonly RESET_TABLE = ProgrammingGradingChartsDirective.RESET_TABLE; readonly chartFilterType = ChartFilterType; readonly ProgrammingLanguage = ProgrammingLanguage; + protected readonly isCommunicationEnabled = isCommunicationEnabled; // We have to separate these test cases in order to separate the table and chart presentation if the table is filtered by the chart staticCodeAnalysisCategoriesForTable: StaticCodeAnalysisCategory[] = []; diff --git a/src/main/webapp/content/icons/icons.ts b/src/main/webapp/content/icons/icons.ts index f5588768724b..cabc6c1d4613 100644 --- a/src/main/webapp/content/icons/icons.ts +++ b/src/main/webapp/content/icons/icons.ts @@ -42,8 +42,23 @@ export const facSaveWarning: IconDefinition = { ], } as IconDefinition; +export const facDetails: IconDefinition = { + prefix: 'fac' as IconPrefix, + iconName: 'details' as IconName, + icon: [ + 24, // SVG view box width + 24, // SVG view box height + [], + '', + [ + 'M2.25 21C2.25 21.4125 2.5875 21.75 3 21.75H12.1424C13.196 22.3848 14.4303 22.75 15.75 22.75C16.3886 22.75 17.0073 22.6645 17.5951 22.5043C17.0752 23.3981 16.1068 24 15 24H3C1.34531 24 0 22.6547 0 21V3C0 1.34531 1.34531 0 3 0H10.7578C11.5547 0 12.3141 0.314063 12.8766 0.876563L17.1234 5.11875C17.6859 5.68125 18 6.44531 18 7.24219V9.11945C17.2939 8.87991 16.5371 8.75 15.75 8.75V7.5H12C11.1703 7.5 10.5 6.82969 10.5 6V2.25H3C2.5875 2.25 2.25 2.5875 2.25 3V21Z M9.83815 12H5.625C5.00156 12 4.5 12.5016 4.5 13.125C4.5 13.7484 5.00156 14.25 5.625 14.25H8.91109C9.08828 13.4384 9.40626 12.6795 9.83815 12Z M8.78971 16.5H5.625C5.00156 16.5 4.5 17.0016 4.5 17.625C4.5 18.2484 5.00156 18.75 5.625 18.75H9.42363C9.09363 18.0553 8.87467 17.2977 8.78971 16.5Z M21.3756 15.6867C21.3756 16.9416 20.9681 18.1008 20.2818 19.0413L23.7436 22.5052C24.0855 22.8469 24.0855 23.4019 23.7436 23.7437C23.4018 24.0854 22.8467 24.0854 22.5049 23.7437L19.043 20.2797C18.1023 20.9687 16.9429 21.3733 15.6878 21.3733C12.5458 21.3733 10 18.828 10 15.6867C10 12.5453 12.5458 10 15.6878 10C18.8297 10 21.3756 12.5453 21.3756 15.6867ZM15.6878 19.6236C16.2049 19.6236 16.7169 19.5218 17.1947 19.3239C17.6724 19.1261 18.1065 18.8361 18.4721 18.4705C18.8378 18.1049 19.1278 17.6709 19.3257 17.1933C19.5236 16.7156 19.6255 16.2037 19.6255 15.6867C19.6255 15.1697 19.5236 14.6577 19.3257 14.1801C19.1278 13.7024 18.8378 13.2684 18.4721 12.9028C18.1065 12.5373 17.6724 12.2473 17.1947 12.0494C16.7169 11.8516 16.2049 11.7497 15.6878 11.7497C15.1707 11.7497 14.6586 11.8516 14.1809 12.0494C13.7031 12.2473 13.2691 12.5373 12.9034 12.9028C12.5378 13.2684 12.2477 13.7024 12.0498 14.1801C11.8519 14.6577 11.7501 15.1697 11.7501 15.6867C11.7501 16.2037 11.8519 16.7156 12.0498 17.1933C12.2477 17.6709 12.5378 18.1049 12.9034 18.4705C13.2691 18.8361 13.7031 19.1261 14.1809 19.3239C14.6586 19.5218 15.1707 19.6236 15.6878 19.6236Z', + ], + ], +} as IconDefinition; + export const artemisIconPack: IconPack = { facSidebar, facSaveSuccess, facSaveWarning, + facDetails, }; diff --git a/src/main/webapp/i18n/de/programmingExercise.json b/src/main/webapp/i18n/de/programmingExercise.json index 41533417cf1e..9a84f1b04a8e 100644 --- a/src/main/webapp/i18n/de/programmingExercise.json +++ b/src/main/webapp/i18n/de/programmingExercise.json @@ -357,6 +357,7 @@ "filter": "Filter", "noData": "Es konnten keine Feedback Einträge für die Programmieraufgabe gefunden werden.", "noDataFilter": "Für den spezifizierten Filter oder Suchbegriff konnten keine Feedback Einträge gefunden werden.", + "channelSuccess": "Kanal {{channelName}} erfolgreich erstellt.", "feedbackModal": { "header": "Fehlerdetails", "feedbackTitle": "Feedback zu Testfällen", @@ -379,6 +380,36 @@ "repository": "Repository", "totalItems": "Insgesamt {{count}} Element(e)", "error": "Beim Laden der betroffenen Studierenden ist ein Fehler aufgetreten." + }, + "feedbackDetailChannel": { + "title": "Erstelle einen Feedback-Diskussionskanal", + "studentNumber": "Anzahl der Studierende die dem Kanal hinzugefügt werden: {{count}}", + "description": "Dieser Kanal soll Diskussionen über spezifisches Feedback zur bereitgestellten Übung erleichtern. Es ermöglicht allen betroffenen Studierenden und Lehrenden, zusammenzuarbeiten und das Feedback effektiv anzugehen.", + "label": "Kanalname", + "channelVisibility": "Kanal Sichtbarkeit", + "visibilityPublic": "Öffentlich", + "visibilityPrivate": "Privat", + "announcementChannel": "Ankündigungskanal", + "announcementChannelYes": "Ja", + "announcementChannelNo": "Nein", + "createAndNavigateLabel": "Erstellen und Navigieren", + "createAndNavigateDescription": "Erstellt den Kanal und navigiert automatisch zum Kanal.", + "createChannelLabel": "Kanal erstellen:", + "createChannelDescription": "Erstellt den Kanal, ohne die Seite zu verlassen.", + "createChannelButton": "Kanal erstellen", + "placeholder": "Dieses Feld bitte nicht leer lassen", + "descriptionLabel": "Kanalbeschreibung", + "requiredValidationErrorName": "Der Kanalname ist ein Pflichtfeld.", + "maxLengthValidationErrorName": "Kanalname kann max {{ max }} Zeichen lang sein!", + "regexValidationErrorName": "Namen können nur Kleinbuchstaben, Zahlen und Striche enthalten. Nur Artemis kann Kanäle erstellen, die mit $ beginnen.", + "requiredValidationErrorDescription": "Die Kanalbeschreibung ist ein Pflichtfeld.", + "maxLengthValidationErrorDescription": "Kanalbeschreibung kann max {{ max }} Zeichen lang sein!", + "confirmationModal": { + "header": "Kanal Erstellen bestätigen", + "confirmationMessage": "Bist du sicher, dass du diesen Kanal erstellen möchten? Dies wird einen Kanal mit {{count}} Student(en) erstellen.", + "cancel": "Abbrechen", + "confirm": "Erstellen" + } } }, "help": { diff --git a/src/main/webapp/i18n/en/programmingExercise.json b/src/main/webapp/i18n/en/programmingExercise.json index d3a53f20f180..feff9adb303d 100644 --- a/src/main/webapp/i18n/en/programmingExercise.json +++ b/src/main/webapp/i18n/en/programmingExercise.json @@ -357,6 +357,7 @@ "filter": "Filters", "noData": "No Feedback Entries could be found for the Programming Exercise.", "noDataFilter": "No Feedback Entries could be found for the specified filter or search Term.", + "channelSuccess": "Channel {{channelName}} successfully created.", "feedbackModal": { "header": "Error Details", "feedbackTitle": "Test Case Feedback", @@ -379,6 +380,36 @@ "repository": "Repository", "totalItems": "In total {{count}} item(s)", "error": "There was an error while loading the affected Students for this feedback." + }, + "feedbackDetailChannel": { + "title": "Create Feedback Discussion Channel", + "studentNumber": "Number of students which will be added to the channel: {{count}}", + "description": "This channel is intended to facilitate discussions around specific feedback for the provided exercise feedback. It will allow all affected students and instructors to collaborate and address the feedback effectively.", + "label": "Channel Name", + "channelVisibility": "Channel Visibility", + "visibilityPublic": "Public", + "visibilityPrivate": "Private", + "announcementChannel": "Announcement Channel", + "announcementChannelYes": "Yes", + "announcementChannelNo": "No", + "createAndNavigateLabel": "Create and Navigate", + "createAndNavigateDescription": "Creates the channel and automatically navigates to the channel.", + "createChannelLabel": "Create Channel:", + "createChannelDescription": "Creates the channel without navigating away from this page.", + "createChannelButton": "Create Channel", + "placeholder": "This field should not be empty", + "descriptionLabel": "Description", + "requiredValidationErrorName": "The channel name is required.", + "maxLengthValidationErrorName": "Channel name can be max {{ max }} characters long!", + "regexValidationErrorName": "Names can only contain lowercase letters, numbers, and dashes. Only Artemis can create channels that start with a $.", + "requiredValidationErrorDescription": "The channel description is required.", + "maxLengthValidationErrorDescription": "Channel description can be max {{ max }} characters long!", + "confirmationModal": { + "header": "Confirm Channel Creation", + "confirmationMessage": "Are you sure you want to create this channel? This will create a channel with {{count}} student(s).", + "cancel": "Cancel", + "confirm": "Create" + } } }, "help": { diff --git a/src/test/java/de/tum/cit/aet/artemis/assessment/ResultServiceIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/assessment/ResultServiceIntegrationTest.java index 6b02b3e4d247..b5834d6a66bf 100644 --- a/src/test/java/de/tum/cit/aet/artemis/assessment/ResultServiceIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/assessment/ResultServiceIntegrationTest.java @@ -903,4 +903,43 @@ void testGetParticipationForFeedbackId() throws Exception { assertThat(jsonNode.has("empty")).isTrue(); } + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCountAffectedStudentsByFeedbackDetailText() throws Exception { + StudentParticipation participation1 = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student1"); + StudentParticipation participation2 = participationUtilService.createAndSaveParticipationForExercise(programmingExercise, TEST_PREFIX + "student2"); + Result result1 = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation1); + Result result2 = participationUtilService.addResultToParticipation(AssessmentType.AUTOMATIC, null, participation2); + participationUtilService.addVariousFeedbackTypeFeedbacksToResult(result1); + ProgrammingExerciseTestCase testCase = programmingExerciseUtilService.addTestCaseToProgrammingExercise(programmingExercise, "test1"); + testCase.setId(1L); + + Feedback feedback1 = new Feedback(); + feedback1.setPositive(false); + feedback1.setDetailText("SampleFeedback"); + feedback1.setTestCase(testCase); + participationUtilService.addFeedbackToResult(feedback1, result1); + + Feedback feedback2 = new Feedback(); + feedback2.setPositive(false); + feedback2.setDetailText("SampleFeedback"); + feedback2.setTestCase(testCase); + participationUtilService.addFeedbackToResult(feedback2, result2); + + String url = "/api/exercises/" + programmingExercise.getId() + "/feedback-detail/affected-students?detailText=SampleFeedback"; + long affectedStudentsCount = request.get(url, HttpStatus.OK, Long.class); + + assertThat(affectedStudentsCount).isInstanceOf(Long.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void testCountAffectedStudentsByFeedbackDetailText_NoMatch() throws Exception { + ProgrammingExercise programmingExercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course); + + String url = "/api/exercises/" + programmingExercise.getId() + "/feedback-detail/affected-students?detailText=NonexistentFeedback"; + long affectedStudentsCount = request.get(url, HttpStatus.OK, Long.class); + + assertThat(affectedStudentsCount).isEqualTo(0); + } } diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java index c0aa140ae963..3bb88f191080 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/ChannelIntegrationTest.java @@ -1,6 +1,7 @@ package de.tum.cit.aet.artemis.communication; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Fail.fail; import java.time.ZonedDateTime; import java.util.Arrays; @@ -22,6 +23,7 @@ import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.dto.ChannelDTO; import de.tum.cit.aet.artemis.communication.dto.ChannelIdAndNameDTO; +import de.tum.cit.aet.artemis.communication.dto.FeedbackChannelRequestDTO; import de.tum.cit.aet.artemis.communication.dto.MetisCrudAction; import de.tum.cit.aet.artemis.communication.util.ConversationUtilService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -32,6 +34,8 @@ import de.tum.cit.aet.artemis.lecture.domain.Lecture; import de.tum.cit.aet.artemis.lecture.repository.LectureRepository; import de.tum.cit.aet.artemis.lecture.util.LectureUtilService; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.util.ProgrammingExerciseUtilService; import de.tum.cit.aet.artemis.text.domain.TextExercise; import de.tum.cit.aet.artemis.text.util.TextExerciseUtilService; import de.tum.cit.aet.artemis.tutorialgroup.service.TutorialGroupChannelManagementService; @@ -63,6 +67,9 @@ class ChannelIntegrationTest extends AbstractConversationTest { @Autowired private ConversationUtilService conversationUtilService; + @Autowired + private ProgrammingExerciseUtilService programmingExerciseUtilService; + @BeforeEach @Override void setupTestScenario() throws Exception { @@ -901,6 +908,60 @@ void getLectureChannel_asCourseStudent_shouldGetLectureChannel() throws Exceptio lectureRepository.deleteById(lecture.getId()); } + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "STUDENT") + void createFeedbackChannel_asStudent_shouldReturnForbidden() { + Course course = programmingExerciseUtilService.addCourseWithOneProgrammingExercise(); + ProgrammingExercise programmingExercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course); + + ChannelDTO channelDTO = new ChannelDTO(); + channelDTO.setName("feedback-channel"); + channelDTO.setDescription("Discussion channel for feedback"); + channelDTO.setIsPublic(true); + channelDTO.setIsAnnouncementChannel(false); + + FeedbackChannelRequestDTO feedbackChannelRequest = new FeedbackChannelRequestDTO(channelDTO, "Sample feedback text"); + + String BASE_ENDPOINT = "api/courses/{courseId}/{exerciseId}/feedback-channel"; + + try { + request.postWithoutResponseBody(BASE_ENDPOINT.replace("{courseId}", course.getId().toString()).replace("{exerciseId}", programmingExercise.getId().toString()), + feedbackChannelRequest, HttpStatus.FORBIDDEN); + } + catch (Exception e) { + fail("There was an error executing the post request.", e); + } + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void createFeedbackChannel_asInstructor_shouldCreateChannel() { + Long courseId = 1L; + Long exerciseId = 1L; + ChannelDTO channelDTO = new ChannelDTO(); + channelDTO.setName("feedback-channel"); + channelDTO.setDescription("Discussion channel for feedback"); + channelDTO.setIsPublic(true); + channelDTO.setIsAnnouncementChannel(false); + + FeedbackChannelRequestDTO feedbackChannelRequest = new FeedbackChannelRequestDTO(channelDTO, "Sample feedback text"); + + String BASE_ENDPOINT = "/api/courses/{courseId}/{exerciseId}/feedback-channel"; + + ChannelDTO response = null; + try { + response = request.postWithResponseBody(BASE_ENDPOINT.replace("{courseId}", courseId.toString()).replace("{exerciseId}", exerciseId.toString()), feedbackChannelRequest, + ChannelDTO.class, HttpStatus.CREATED); + } + catch (Exception e) { + fail("Failed to create feedback channel", e); + } + + assertThat(response).isNotNull(); + assertThat(response.getName()).isEqualTo("feedback-channel"); + assertThat(response.getDescription()).isEqualTo("Discussion channel for feedback"); + } + private void testArchivalChangeWorks(ChannelDTO channel, boolean isPublicChannel, boolean shouldArchive) throws Exception { // prepare channel in db if (shouldArchive) { diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts index 09d22ab65dd6..efba3a7ba4de 100644 --- a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.component.spec.ts @@ -9,6 +9,10 @@ import { LocalStorageService } from 'ngx-webstorage'; import '@angular/localize/init'; import { FeedbackFilterModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-filter-modal.component'; import { AffectedStudentsModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-affected-students-modal.component'; +import { FeedbackDetailChannelModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component'; +import { Subject } from 'rxjs'; +import { ChannelDTO } from 'app/entities/metis/conversation/channel.model'; +import { AlertService } from 'app/core/util/alert.service'; describe('FeedbackAnalysisComponent', () => { let fixture: ComponentFixture; @@ -16,6 +20,10 @@ describe('FeedbackAnalysisComponent', () => { let feedbackAnalysisService: FeedbackAnalysisService; let searchSpy: jest.SpyInstance; let localStorageService: LocalStorageService; + let modalService: NgbModal; + let alertService: AlertService; + let modalSpy: jest.SpyInstance; + let createChannelSpy: jest.SpyInstance; const feedbackMock: FeedbackDetail[] = [ { @@ -63,11 +71,27 @@ describe('FeedbackAnalysisComponent', () => { component = fixture.componentInstance; feedbackAnalysisService = fixture.debugElement.injector.get(FeedbackAnalysisService); localStorageService = fixture.debugElement.injector.get(LocalStorageService); + modalService = fixture.debugElement.injector.get(NgbModal); + alertService = fixture.debugElement.injector.get(AlertService); jest.spyOn(localStorageService, 'retrieve').mockReturnValue([]); searchSpy = jest.spyOn(feedbackAnalysisService, 'search').mockResolvedValue(feedbackResponseMock); + const mockFormSubmitted = new Subject<{ channelDto: ChannelDTO; navigate: boolean }>(); + modalSpy = jest.spyOn(TestBed.inject(NgbModal), 'open').mockReturnValue({ + componentInstance: { + formSubmitted: mockFormSubmitted, + affectedStudentsCount: null, + feedbackDetail: null, + }, + result: Promise.resolve(), + } as any); + + jest.spyOn(feedbackAnalysisService, 'getAffectedStudentCount').mockResolvedValue(10); + createChannelSpy = jest.spyOn(feedbackAnalysisService, 'createChannel').mockResolvedValue({ id: 123 } as ChannelDTO); + + jest.spyOn(fixture.debugElement.injector.get(AlertService), 'success'); + jest.spyOn(fixture.debugElement.injector.get(AlertService), 'error'); - // Initial input setup fixture.componentRef.setInput('exerciseId', 1); fixture.componentRef.setInput('exerciseTitle', 'Sample Exercise Title'); @@ -254,4 +278,44 @@ describe('FeedbackAnalysisComponent', () => { expect(modalSpy).toHaveBeenCalledOnce(); }); }); + + it('should open the feedback detail channel modal', async () => { + const formSubmitted = new Subject<{ channelDto: ChannelDTO; navigate: boolean }>(); + const modalRef = { + result: Promise.resolve('mocked result'), + componentInstance: { + formSubmitted, + affectedStudentsCount: null, + feedbackDetail: null, + }, + } as any; + jest.spyOn(modalService, 'open').mockReturnValue(modalRef); + await component.openFeedbackDetailChannelModal(feedbackMock[0]); + expect(modalService.open).toHaveBeenCalledWith(FeedbackDetailChannelModalComponent, { centered: true, size: 'lg' }); + }); + + it('should handle errors during channel creation gracefully', async () => { + const formSubmitted = new Subject<{ channelDto: ChannelDTO; navigate: boolean }>(); + const modalRef = { + result: Promise.resolve('mocked result'), + componentInstance: { + formSubmitted, + affectedStudentsCount: null, + feedbackDetail: null, + }, + } as any; + jest.spyOn(modalService, 'open').mockReturnValue(modalRef); + createChannelSpy.mockRejectedValue(new Error('Error creating channel')); + await component.openFeedbackDetailChannelModal(feedbackMock[0]); + formSubmitted.next({ channelDto: { name: 'Test Channel' } as ChannelDTO, navigate: true }); + expect(alertService.error).toHaveBeenCalledOnce(); + }); + + it('should not proceed if modal is already open', async () => { + component['isFeedbackDetailChannelModalOpen'] = true; + const feedbackDetail = feedbackMock[0]; + await component.openFeedbackDetailChannelModal(feedbackDetail); + expect(component['isFeedbackDetailChannelModalOpen']).toBeTrue(); + expect(modalSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts index 23b9bfaf0f49..45bb903fe421 100644 --- a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/feedback-analysis.service.spec.ts @@ -132,4 +132,61 @@ describe('FeedbackAnalysisService', () => { expect(result.content[1].firstName).toBe('Jane'); }); }); + + describe('getAffectedStudentCount', () => { + it('should retrieve the count of affected students for a feedback detail text', async () => { + const exerciseId = 1; + const feedbackDetailText = 'Test feedback detail'; + const affectedStudentCountMock = 42; + + const responsePromise = service.getAffectedStudentCount(exerciseId, feedbackDetailText); + + const req = httpMock.expectOne(`api/exercises/${exerciseId}/feedback-detail/affected-students?detailText=${encodeURIComponent(feedbackDetailText)}`); + expect(req.request.method).toBe('GET'); + req.flush(affectedStudentCountMock); + + const result = await responsePromise; + expect(result).toBe(affectedStudentCountMock); + }); + }); + + describe('createChannel', () => { + it('should send a POST request to create a feedback-specific channel and return the created channel DTO', async () => { + const courseId = 1; + const exerciseId = 2; + + const channelDtoMock = { + name: 'feedback-channel', + description: 'Discussion channel for feedback', + isPublic: true, + isAnnouncementChannel: false, + }; + + const feedbackChannelRequestMock = { + channel: channelDtoMock, + feedbackDetailText: 'Sample feedback detail text', + }; + + const createdChannelMock = { + id: 1001, + name: 'feedback-channel', + description: 'Discussion channel for feedback', + isPublic: true, + isAnnouncementChannel: false, + }; + + const responsePromise = service.createChannel(courseId, exerciseId, feedbackChannelRequestMock); + + const req = httpMock.expectOne(`api/courses/${courseId}/${exerciseId}/feedback-channel`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(feedbackChannelRequestMock); + + req.flush(createdChannelMock); + + const result = await responsePromise; + expect(result).toEqual(createdChannelMock); + expect(result.name).toBe('feedback-channel'); + expect(result.description).toBe('Discussion channel for feedback'); + }); + }); }); diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/confirm-feedback-channel-creation-modal.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/confirm-feedback-channel-creation-modal.component.spec.ts new file mode 100644 index 000000000000..5c59597134d6 --- /dev/null +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/confirm-feedback-channel-creation-modal.component.spec.ts @@ -0,0 +1,39 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ConfirmFeedbackChannelCreationModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/confirm-feedback-channel-creation-modal.component'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('ConfirmFeedbackChannelCreationModalComponent', () => { + let fixture: ComponentFixture; + let component: ConfirmFeedbackChannelCreationModalComponent; + let activeModal: NgbActiveModal; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), ConfirmFeedbackChannelCreationModalComponent], + providers: [NgbActiveModal], + }).compileComponents(); + + fixture = TestBed.createComponent(ConfirmFeedbackChannelCreationModalComponent); + component = fixture.componentInstance; + activeModal = TestBed.inject(NgbActiveModal); + fixture.componentRef.setInput('affectedStudentsCount', 42); + fixture.detectChanges(); + }); + + it('should initialize with the provided affectedStudentsCount', () => { + expect(component.affectedStudentsCount()).toBe(42); + }); + + it('should call close on activeModal with true when confirm is triggered', () => { + const closeSpy = jest.spyOn(activeModal, 'close'); + component.confirm(); + expect(closeSpy).toHaveBeenCalledExactlyOnceWith(true); + }); + + it('should call dismiss on activeModal when dismiss is triggered', () => { + const dismissSpy = jest.spyOn(activeModal, 'dismiss'); + component.dismiss(); + expect(dismissSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-detail-channel-modal.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-detail-channel-modal.component.spec.ts new file mode 100644 index 000000000000..74928d400dd3 --- /dev/null +++ b/src/test/javascript/spec/component/programming-exercise/feedback-analysis/modals/feedback-detail-channel-modal.component.spec.ts @@ -0,0 +1,153 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FeedbackDetailChannelModalComponent } from 'app/exercises/programming/manage/grading/feedback-analysis/Modal/feedback-detail-channel-modal.component'; +import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('FeedbackDetailChannelModalComponent', () => { + let fixture: ComponentFixture; + let component: FeedbackDetailChannelModalComponent; + let activeModal: NgbActiveModal; + let modalService: NgbModal; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), ReactiveFormsModule, FeedbackDetailChannelModalComponent], + providers: [NgbActiveModal, NgbModal, FormBuilder], + }).compileComponents(); + + fixture = TestBed.createComponent(FeedbackDetailChannelModalComponent); + component = fixture.componentInstance; + activeModal = TestBed.inject(NgbActiveModal); + modalService = TestBed.inject(NgbModal); + + fixture.componentRef.setInput('affectedStudentsCount', 42); + fixture.componentRef.setInput('feedbackDetail', { + detailText: 'Sample feedback', + concatenatedFeedbackIds: [1], + count: 10, + relativeCount: 50, + testCaseName: 'testCase1', + taskName: 'Task 1', + errorCategory: 'StudentError', + } as any); + fixture.componentInstance.isConfirmModalOpen.set(false); + fixture.detectChanges(); + }); + + it('should initialize form and inputs', () => { + expect(component.affectedStudentsCount()).toBe(42); + expect(component.feedbackDetail().detailText).toBe('Sample feedback'); + expect(component.form).toBeDefined(); + expect(component.form.valid).toBeFalse(); + }); + + it('should call activeModal.close when closeModal is triggered', () => { + const closeSpy = jest.spyOn(activeModal, 'close'); + component.closeModal(); + expect(closeSpy).toHaveBeenCalledOnce(); + }); + + it('should call activeModal.dismiss when dismissModal is triggered', () => { + const dismissSpy = jest.spyOn(activeModal, 'dismiss'); + component.dismissModal(); + expect(dismissSpy).toHaveBeenCalledOnce(); + }); + + it('should open confirmation modal and emit formSubmitted on successful confirmation', async () => { + jest.spyOn(component, 'handleModal').mockResolvedValue(true); + + component.form.setValue({ + name: 'channel', + description: 'channelDescription', + isPublic: true, + isAnnouncementChannel: false, + }); + + const formSubmittedSpy = jest.spyOn(component.formSubmitted, 'emit'); + await component.submitForm(false); + + expect(component.isConfirmModalOpen()).toBeFalse(); + expect(formSubmittedSpy).toHaveBeenCalledExactlyOnceWith({ + channelDto: expect.objectContaining({ + creationDate: undefined, + creator: undefined, + description: 'channelDescription', + hasChannelModerationRights: undefined, + hasUnreadMessage: undefined, + id: undefined, + isAnnouncementChannel: false, + isArchived: undefined, + isChannelModerator: undefined, + isCourseWide: undefined, + isCreator: undefined, + isFavorite: undefined, + isHidden: undefined, + isMember: undefined, + isMuted: undefined, + isPublic: true, + lastMessageDate: undefined, + lastReadDate: undefined, + name: 'channel', + numberOfMembers: undefined, + subType: undefined, + subTypeReferenceId: undefined, + topic: undefined, + tutorialGroupId: undefined, + tutorialGroupTitle: undefined, + type: 'channel', + unreadMessagesCount: undefined, + }), + navigate: false, + }); + }); + + it('should call handleModal and proceed if confirmed', async () => { + jest.spyOn(component, 'handleModal').mockResolvedValue(true); + const formSubmittedSpy = jest.spyOn(component.formSubmitted, 'emit'); + + component.form.setValue({ + name: 'channel', + description: 'channelDescription', + isPublic: true, + isAnnouncementChannel: false, + }); + + await component.submitForm(false); + + expect(component.handleModal).toHaveBeenCalledOnce(); + expect(formSubmittedSpy).toHaveBeenCalledExactlyOnceWith({ + channelDto: expect.objectContaining({ + name: 'channel', + description: 'channelDescription', + isPublic: true, + isAnnouncementChannel: false, + }), + navigate: false, + }); + }); + + it('should not proceed if modal is dismissed', async () => { + jest.spyOn(component, 'handleModal').mockResolvedValue(false); + + const formSubmittedSpy = jest.spyOn(component.formSubmitted, 'emit'); + + component.form.setValue({ + name: 'channel', + description: 'channelDescription', + isPublic: true, + isAnnouncementChannel: false, + }); + + await component.submitForm(false); + + expect(component.handleModal).toHaveBeenCalledOnce(); + expect(formSubmittedSpy).not.toHaveBeenCalledOnce(); + }); + + it('should not open confirmation modal if form is invalid', async () => { + const modalSpy = jest.spyOn(modalService, 'open'); + await component.submitForm(true); + expect(modalSpy).not.toHaveBeenCalledOnce(); + }); +}); From 34d991481edafa33bf6dcf23617443bc12deef01 Mon Sep 17 00:00:00 2001 From: Murad Talibov <56686446+muradium@users.noreply.github.com> Date: Tue, 26 Nov 2024 22:50:12 +0100 Subject: [PATCH 10/15] Development: Add exercise update announcement e2e tests (#9784) --- .../exercise-groups.component.html | 2 +- .../e2e/exam/ExamParticipation.spec.ts | 62 ++++++++++++++++++- src/test/playwright/support/fixtures.ts | 5 ++ .../support/pageobjects/exam/EditExamPage.ts | 13 ++++ .../exam/ExamExerciseGroupsPage.ts | 4 ++ .../exam/ExamParticipationActions.ts | 38 ++++++++++++ .../pageobjects/exam/ModalDialogBox.ts | 12 ++++ .../text/TextExerciseCreationPage.ts | 47 +++++++++++--- 8 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 src/test/playwright/support/pageobjects/exam/EditExamPage.ts diff --git a/src/main/webapp/app/exam/manage/exercise-groups/exercise-groups.component.html b/src/main/webapp/app/exam/manage/exercise-groups/exercise-groups.component.html index 8b72a9f8f417..43cd6963ce39 100644 --- a/src/main/webapp/app/exam/manage/exercise-groups/exercise-groups.component.html +++ b/src/main/webapp/app/exam/manage/exercise-groups/exercise-groups.component.html @@ -242,7 +242,7 @@
{{ exerciseGroup.title }}
@for (exercise of exerciseGroup.exercises; track exercise) { - + @if (course.isAtLeastEditor) { { test.describe('Exam announcements', { tag: '@slow' }, () => { let exam: Exam; const students = [studentOne, studentTwo]; + let exercise: Exercise; test.beforeEach('Create exam', async ({ login, examAPIRequests, examExerciseGroupCreation }) => { await login(admin); exam = await createExam(course, examAPIRequests); - const exercise = await examExerciseGroupCreation.addGroupWithExercise(exam, ExerciseType.TEXT, { textFixture }); + exercise = await examExerciseGroupCreation.addGroupWithExercise(exam, ExerciseType.TEXT, { textFixture }); exerciseArray.push(exercise); for (const student of students) { await examAPIRequests.registerStudentForExam(exam, student); @@ -347,6 +350,61 @@ test.describe('Exam participation', () => { await examParticipationActions.checkExamTimeLeft('29'); } }); + + test( + 'Instructor changes problem statement and all participants are informed', + { tag: '@fast' }, + async ({ browser, login, navigationBar, courseManagement, examManagement, examExerciseGroups, editExam, textExerciseCreation }) => { + await login(instructor); + await navigationBar.openCourseManagement(); + await courseManagement.openExamsOfCourse(course.id!); + await examManagement.openExam(exam.id!); + + const studentPages = []; + + for (const student of students) { + const studentContext = await browser.newContext(); + const studentPage = await studentContext.newPage(); + studentPages.push(studentPage); + + await Commands.login(studentPage, student); + await studentPage.goto(`/courses/${course.id!}/exams/${exam.id!}`); + const examStartEnd = new ExamStartEndPage(studentPage); + await examStartEnd.startExam(false); + const examNavigation = new ExamNavigationBar(studentPage); + await examNavigation.openOrSaveExerciseByTitle(exercise.exerciseGroup!.title!); + } + + await editExam.openExerciseGroups(); + await examExerciseGroups.clickEditExercise(exercise.exerciseGroup!.id!, exercise.id!); + + const problemStatementText = textExerciseTemplate.problemStatement; + const startOfChangesIndex = problemStatementText.lastIndexOf(' ') + 1; + const removedText = problemStatementText.slice(startOfChangesIndex); + const unchangedText = problemStatementText.slice(0, startOfChangesIndex); + const addedText = 'Changed'; + await textExerciseCreation.clearProblemStatement(); + await textExerciseCreation.typeProblemStatement(unchangedText + addedText); + await textExerciseCreation.create(); + + for (const studentPage of studentPages) { + const modalDialog = new ModalDialogBox(studentPage); + const exerciseUpdateMessage = `The problem statement of the exercise '${exercise.exerciseGroup!.title!}' was updated. Please open the exercise to see the changes.`; + await modalDialog.checkDialogType('Problem Statement Update'); + await modalDialog.checkDialogMessage(exerciseUpdateMessage); + await modalDialog.checkDialogAuthor(instructor.username); + await modalDialog.pressModalButton('Navigate to exercise'); + const examParticipationActions = new ExamParticipationActions(studentPage); + await examParticipationActions.checkExerciseProblemStatementDifference([ + { text: unchangedText, differenceType: TextDifferenceType.NONE }, + { text: removedText, differenceType: TextDifferenceType.DELETE }, + { text: addedText, differenceType: TextDifferenceType.ADD }, + ]); + await studentPage.locator('#highlightDiffButton').click(); + await examParticipationActions.checkExerciseProblemStatementDifference([{ text: unchangedText + addedText, differenceType: TextDifferenceType.NONE }]); + } + }, + ); }); test.afterEach('Delete course', async ({ courseManagementAPIRequests }) => { diff --git a/src/test/playwright/support/fixtures.ts b/src/test/playwright/support/fixtures.ts index dbf28d32cc37..24d95e944326 100644 --- a/src/test/playwright/support/fixtures.ts +++ b/src/test/playwright/support/fixtures.ts @@ -67,6 +67,7 @@ import { QuizExerciseOverviewPage } from './pageobjects/exercises/quiz/QuizExerc import { QuizExerciseParticipationPage } from './pageobjects/exercises/quiz/QuizExerciseParticipationPage'; import { ModalDialogBox } from './pageobjects/exam/ModalDialogBox'; import { ExamParticipationActions } from './pageobjects/exam/ExamParticipationActions'; +import { EditExamPage } from './pageobjects/exam/EditExamPage'; /* * Define custom types for fixtures @@ -96,6 +97,7 @@ export type ArtemisPageObjects = { courseCommunication: CourseCommunicationPage; lectureManagement: LectureManagementPage; lectureCreation: LectureCreationPage; + editExam: EditExamPage; examCreation: ExamCreationPage; examDetails: ExamDetailsPage; examExerciseGroupCreation: ExamExerciseGroupCreationPage; @@ -219,6 +221,9 @@ export const test = base.extend { await use(new LectureCreationPage(page)); }, + editExam: async ({ page }, use) => { + await use(new EditExamPage(page)); + }, examCreation: async ({ page }, use) => { await use(new ExamCreationPage(page)); }, diff --git a/src/test/playwright/support/pageobjects/exam/EditExamPage.ts b/src/test/playwright/support/pageobjects/exam/EditExamPage.ts new file mode 100644 index 000000000000..69608a61522c --- /dev/null +++ b/src/test/playwright/support/pageobjects/exam/EditExamPage.ts @@ -0,0 +1,13 @@ +import { Page } from '@playwright/test'; + +export class EditExamPage { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async openExerciseGroups() { + await this.page.locator(`#exercises-button-groups-table`).click(); + } +} diff --git a/src/test/playwright/support/pageobjects/exam/ExamExerciseGroupsPage.ts b/src/test/playwright/support/pageobjects/exam/ExamExerciseGroupsPage.ts index 4e4379f12817..9eb57e9fbc83 100644 --- a/src/test/playwright/support/pageobjects/exam/ExamExerciseGroupsPage.ts +++ b/src/test/playwright/support/pageobjects/exam/ExamExerciseGroupsPage.ts @@ -60,6 +60,10 @@ export class ExamExerciseGroupsPage { await this.page.locator(`#group-${groupID} .add-programming-exercise`).click(); } + async clickEditExercise(groupID: number, exerciseID: number) { + await this.page.locator(`#group-${groupID} #exercise-${exerciseID}`).locator('.btn', { hasText: 'Edit' }).click(); + } + async visitPageViaUrl(courseId: number, examId: number) { await this.page.goto(`course-management/${courseId}/exams/${examId}/exercise-groups`); } diff --git a/src/test/playwright/support/pageobjects/exam/ExamParticipationActions.ts b/src/test/playwright/support/pageobjects/exam/ExamParticipationActions.ts index a7be0510b65a..0fee7970a6ea 100644 --- a/src/test/playwright/support/pageobjects/exam/ExamParticipationActions.ts +++ b/src/test/playwright/support/pageobjects/exam/ExamParticipationActions.ts @@ -24,6 +24,31 @@ export class ExamParticipationActions { await expect(exercise.locator('.exercise-title')).toContainText(title); } + async checkExerciseProblemStatementDifference(differenceSlices: TextDifferenceSlice[]) { + const problemStatementCard = this.page.locator('.card', { hasText: 'Problem Statement' }); + const problemStatementText = problemStatementCard.locator('.markdown-preview').locator('p'); + + if ((await problemStatementText.locator('.diffmod').count()) > 0) { + for (const slice of differenceSlices) { + switch (slice.differenceType) { + case TextDifferenceType.ADD: + await expect(problemStatementText.locator('ins').getByText(slice.text)).toBeVisible(); + break; + case TextDifferenceType.DELETE: + await expect(problemStatementText.locator('del').getByText(slice.text)).toBeVisible(); + break; + case TextDifferenceType.NONE: + await expect(problemStatementText).toContainText(slice.text); + break; + } + } + } else { + const firstSlice = differenceSlices[0]; + expect(firstSlice.differenceType).toBe(TextDifferenceType.NONE); + await expect(problemStatementText).toHaveText(firstSlice.text); + } + } + async checkExamTitle(title: string) { await expect(this.page.locator('#exam-title')).toContainText(title); } @@ -89,3 +114,16 @@ export class ExamParticipationActions { await expect(gradingKeyCard.locator('tr.highlighted').locator('td', { hasText: gradeName })).toBeVisible(); } } + +export class TextDifferenceSlice { + constructor( + public text: string, + public differenceType: TextDifferenceType, + ) {} +} + +export enum TextDifferenceType { + NONE, + ADD, + DELETE, +} diff --git a/src/test/playwright/support/pageobjects/exam/ModalDialogBox.ts b/src/test/playwright/support/pageobjects/exam/ModalDialogBox.ts index 77d7a06f2168..54de2cf3f068 100644 --- a/src/test/playwright/support/pageobjects/exam/ModalDialogBox.ts +++ b/src/test/playwright/support/pageobjects/exam/ModalDialogBox.ts @@ -24,6 +24,10 @@ export class ModalDialogBox { await expect(this.getModalDialogContent().locator('.content').getByText(message)).toBeVisible(); } + async checkDialogType(type: string) { + await expect(this.getModalDialogContent().locator('.type').getByText(type)).toBeVisible(); + } + async checkDialogAuthor(authorUsername: string) { await expect(this.getModalDialogContent().locator('.author').getByText(authorUsername)).toBeVisible(); } @@ -37,4 +41,12 @@ export class ModalDialogBox { async closeDialog() { await this.getModalDialogContent().locator('button').click({ force: true }); } + + async pressModalButton(buttonText: string) { + let buttonLocator = this.getModalDialogContent().locator('button'); + if (buttonText) { + buttonLocator = buttonLocator.filter({ hasText: buttonText }); + } + await buttonLocator.click(); + } } diff --git a/src/test/playwright/support/pageobjects/exercises/text/TextExerciseCreationPage.ts b/src/test/playwright/support/pageobjects/exercises/text/TextExerciseCreationPage.ts index 7d13e7ee1101..b2c934b7f3f6 100644 --- a/src/test/playwright/support/pageobjects/exercises/text/TextExerciseCreationPage.ts +++ b/src/test/playwright/support/pageobjects/exercises/text/TextExerciseCreationPage.ts @@ -1,4 +1,4 @@ -import { Page } from '@playwright/test'; +import { Locator, Page } from '@playwright/test'; import { Dayjs } from 'dayjs'; import { enterDate } from '../../../utils'; import { TEXT_EXERCISE_BASE } from '../../../constants'; @@ -6,6 +6,10 @@ import { TEXT_EXERCISE_BASE } from '../../../constants'; export class TextExerciseCreationPage { private readonly page: Page; + private readonly PROBLEM_STATEMENT_SELECTOR = '#problemStatement'; + private readonly EXAMPLE_SOLUTION_SELECTOR = '#exampleSolution'; + private readonly ASSESSMENT_INSTRUCTIONS_SELECTOR = '#gradingInstructions'; + constructor(page: Page) { this.page = page; } @@ -33,15 +37,33 @@ export class TextExerciseCreationPage { } async typeProblemStatement(statement: string) { - await this.typeText('#problemStatement', statement); + const textEditor = this.getTextEditorLocator(this.PROBLEM_STATEMENT_SELECTOR); + await this.typeText(textEditor, statement); + } + + async clearProblemStatement() { + const textEditor = this.getTextEditorLocator(this.PROBLEM_STATEMENT_SELECTOR); + await this.clearText(textEditor); } async typeExampleSolution(statement: string) { - await this.typeText('#exampleSolution', statement); + const textEditor = this.getTextEditorLocator(this.EXAMPLE_SOLUTION_SELECTOR); + await this.typeText(textEditor, statement); + } + + async clearExampleSolution() { + const textEditor = this.getTextEditorLocator(this.EXAMPLE_SOLUTION_SELECTOR); + await this.clearText(textEditor); } async typeAssessmentInstructions(statement: string) { - await this.typeText('#gradingInstructions', statement); + const textEditor = this.getTextEditorLocator(this.ASSESSMENT_INSTRUCTIONS_SELECTOR); + await this.typeText(textEditor, statement); + } + + async clearAssessmentInstructions() { + const textEditor = this.getTextEditorLocator(this.ASSESSMENT_INSTRUCTIONS_SELECTOR); + await this.clearText(textEditor); } async create() { @@ -56,9 +78,18 @@ export class TextExerciseCreationPage { return await responsePromise; } - private async typeText(selector: string, text: string) { - const textField = this.page.locator(selector).locator('.monaco-editor'); - await textField.click(); - await textField.pressSequentially(text); + private getTextEditorLocator(selector: string) { + return this.page.locator(selector).locator('.monaco-editor'); + } + + private async clearText(textEditor: Locator) { + await textEditor.click(); + await textEditor.press('Control+a'); + await textEditor.press('Backspace'); + } + + private async typeText(textEditor: Locator, text: string) { + await textEditor.click(); + await textEditor.pressSequentially(text); } } From 08513442798d1aa5ddd8469660f632e4ccb46b1e Mon Sep 17 00:00:00 2001 From: Paul Rangger <48455539+PaRangger@users.noreply.github.com> Date: Tue, 26 Nov 2024 22:59:19 +0100 Subject: [PATCH 11/15] Communication: Allow user to save messages for later (#9705) --- .../communication/domain/AnswerPost.java | 33 +++ .../artemis/communication/domain/Post.java | 27 +++ .../artemis/communication/domain/Posting.java | 3 + .../communication/domain/PostingType.java | 23 ++ .../communication/domain/SavedPost.java | 83 +++++++ .../communication/domain/SavedPostStatus.java | 23 ++ .../artemis/communication/dto/AuthorDTO.java | 13 ++ .../dto/PostingConversationDTO.java | 34 +++ .../artemis/communication/dto/PostingDTO.java | 40 ++++ .../communication/dto/ReactionDTO.java | 12 + .../repository/AnswerPostRepository.java | 7 + .../ConversationMessageRepository.java | 1 + .../repository/PostRepository.java | 2 + .../repository/SavedPostRepository.java | 143 ++++++++++++ .../service/AnswerMessageService.java | 7 +- .../service/ConversationMessagingService.java | 13 +- .../communication/service/PostingService.java | 29 ++- .../service/ReactionService.java | 2 + .../service/SavedPostScheduleService.java | 68 ++++++ .../service/SavedPostService.java | 123 ++++++++++ .../web/ConversationMessageResource.java | 2 + .../communication/web/SavedPostResource.java | 218 ++++++++++++++++++ .../tum/cit/aet/artemis/core/domain/User.java | 4 + .../core/service/user/UserService.java | 13 +- .../service/PlagiarismAnswerPostService.java | 8 +- .../service/PlagiarismPostService.java | 11 +- .../changelog/20241101121000_changelog.xml | 39 ++++ .../resources/config/liquibase/master.xml | 1 + .../webapp/app/core/user/account.model.ts | 2 +- .../metis/conversation/conversation.model.ts | 1 + .../webapp/app/entities/metis/post.model.ts | 2 - .../app/entities/metis/posting.model.ts | 45 ++++ .../course-conversations.component.html | 10 +- .../course-conversations.component.ts | 86 ++++++- .../course-conversations.module.ts | 4 + .../course-wide-search.component.html | 2 +- .../conversation-messages.component.html | 6 +- .../conversation-messages.component.ts | 31 ++- ...conversation-thread-sidebar.component.html | 4 +- .../posting-summary.component.html | 93 ++++++++ .../posting-summary.component.scss | 44 ++++ .../posting-summary.component.ts | 59 +++++ .../saved-posts/saved-posts.component.html | 33 +++ .../saved-posts/saved-posts.component.scss | 14 ++ .../saved-posts/saved-posts.component.ts | 64 +++++ .../app/overview/course-overview.service.ts | 32 ++- .../answer-post/answer-post.component.html | 36 ++- .../answer-post/answer-post.component.scss | 25 +- .../answer-post/answer-post.component.ts | 27 ++- .../app/shared/metis/metis.component.scss | 6 +- .../webapp/app/shared/metis/metis.service.ts | 55 ++++- .../app/shared/metis/post/post.component.html | 61 +++-- .../app/shared/metis/post/post.component.scss | 20 +- .../app/shared/metis/post/post.component.ts | 13 +- .../posting-content.component.html | 2 +- .../posting-content.components.ts | 14 +- .../answer-post-header.component.ts | 9 - .../post-header/post-header.component.ts | 9 - .../posting-header.directive.ts | 8 +- .../answer-post-reactions-bar.component.html | 7 + .../answer-post-reactions-bar.component.ts | 1 + .../post-reactions-bar.component.html | 9 + .../posting-reactions-bar.directive.ts | 11 + .../app/shared/metis/posting.directive.ts | 16 ++ .../app/shared/metis/saved-post.service.ts | 98 ++++++++ .../sidebar-accordion.component.html | 4 +- .../app/shared/sidebar/sidebar.component.ts | 4 +- src/main/webapp/app/types/sidebar.ts | 6 +- src/main/webapp/i18n/de/metis.json | 18 +- .../webapp/i18n/de/student-dashboard.json | 4 + src/main/webapp/i18n/en/metis.json | 18 +- .../webapp/i18n/en/student-dashboard.json | 4 + .../GroupChatIntegrationTest.java | 1 + .../communication/PostingServiceUnitTest.java | 86 +++++++ .../SavedPostResourceIntegrationTest.java | 129 +++++++++++ .../service/SavedPostScheduleServiceTest.java | 124 ++++++++++ .../service/SavedPostServiceTest.java | 164 +++++++++++++ .../SavedPostTestRepository.java | 11 + .../util/ConversationFactory.java | 17 ++ .../course-conversations.component.spec.ts | 174 +++++++++++++- .../conversation-messages.component.spec.ts | 4 +- .../posting-summary.component.spec.ts | 171 ++++++++++++++ .../saved-posts.component.spec.ts | 145 ++++++++++++ .../answer-post/answer-post.component.spec.ts | 22 ++ .../shared/metis/post/post.component.spec.ts | 22 ++ .../service/mock-metis-service.service.ts | 2 + .../spec/helpers/sample/metis-sample-data.ts | 1 + .../spec/service/metis/metis.service.spec.ts | 51 ++++ .../service/metis/saved-post.service.spec.ts | 149 ++++++++++++ 89 files changed, 3145 insertions(+), 127 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/communication/domain/PostingType.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPost.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPostStatus.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/communication/dto/AuthorDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingConversationDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/communication/dto/ReactionDTO.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/communication/repository/SavedPostRepository.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostScheduleService.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostService.java create mode 100644 src/main/java/de/tum/cit/aet/artemis/communication/web/SavedPostResource.java create mode 100644 src/main/resources/config/liquibase/changelog/20241101121000_changelog.xml create mode 100644 src/main/webapp/app/overview/course-conversations/posting-summary/posting-summary.component.html create mode 100644 src/main/webapp/app/overview/course-conversations/posting-summary/posting-summary.component.scss create mode 100644 src/main/webapp/app/overview/course-conversations/posting-summary/posting-summary.component.ts create mode 100644 src/main/webapp/app/overview/course-conversations/saved-posts/saved-posts.component.html create mode 100644 src/main/webapp/app/overview/course-conversations/saved-posts/saved-posts.component.scss create mode 100644 src/main/webapp/app/overview/course-conversations/saved-posts/saved-posts.component.ts create mode 100644 src/main/webapp/app/shared/metis/saved-post.service.ts create mode 100644 src/test/java/de/tum/cit/aet/artemis/communication/SavedPostResourceIntegrationTest.java create mode 100644 src/test/java/de/tum/cit/aet/artemis/communication/service/SavedPostScheduleServiceTest.java create mode 100644 src/test/java/de/tum/cit/aet/artemis/communication/service/SavedPostServiceTest.java create mode 100644 src/test/java/de/tum/cit/aet/artemis/communication/test_repository/SavedPostTestRepository.java create mode 100644 src/test/javascript/spec/component/overview/course-conversations/posting-summary.component.spec.ts create mode 100644 src/test/javascript/spec/component/overview/course-conversations/saved-posts.component.spec.ts create mode 100644 src/test/javascript/spec/service/metis/saved-post.service.spec.ts diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/AnswerPost.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/AnswerPost.java index aaedef17af0a..9469d9e6d818 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/AnswerPost.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/AnswerPost.java @@ -10,14 +10,18 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import jakarta.persistence.Transient; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.SQLRestriction; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonIncludeProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; import de.tum.cit.aet.artemis.core.domain.Course; /** @@ -35,10 +39,20 @@ public class AnswerPost extends Posting { @OneToMany(mappedBy = "answerPost", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.EAGER) private Set reactions = new HashSet<>(); + /*** + * The value 1 represents an answer post, given by the enum {{@link PostingType}} + */ + @OneToMany(mappedBy = "postId", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) + @SQLRestriction("post_type = 1") + private Set savedPosts = new HashSet<>(); + @ManyToOne @JsonIncludeProperties({ "id", "exercise", "lecture", "course", "courseWideContext", "conversation", "author" }) private Post post; + @Transient + private boolean isSaved = false; + @JsonProperty("resolvesPost") public Boolean doesResolvePost() { return resolvesPost; @@ -76,6 +90,25 @@ public void setPost(Post post) { this.post = post; } + @JsonIgnore + public Set getSavedPosts() { + return savedPosts; + } + + @JsonProperty("isSaved") + public boolean getIsSaved() { + return isSaved; + } + + public void setIsSaved(boolean isSaved) { + this.isSaved = isSaved; + } + + @JsonIgnore + public Conversation getConversation() { + return getPost().getConversation(); + } + /** * Helper method to extract the course an AnswerPost belongs to, which is found in different locations based on the parent Post's context * 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 4ff2d48fedf5..3bb92cb6a540 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 @@ -16,14 +16,17 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.OneToOne; import jakarta.persistence.Table; +import jakarta.persistence.Transient; import jakarta.validation.constraints.Size; import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.SQLRestriction; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonIncludeProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; import de.tum.cit.aet.artemis.core.domain.Course; @@ -54,6 +57,13 @@ public class Post extends Posting { @OneToMany(mappedBy = "post", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.EAGER) private Set answers = new HashSet<>(); + /*** + * The value 0 represents a post, given by the enum {{@link PostingType}} + */ + @OneToMany(mappedBy = "postId", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY) + @SQLRestriction("post_type = 0") + private Set savedPosts = new HashSet<>(); + @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "post_tag", joinColumns = @JoinColumn(name = "post_id")) @Column(name = "text") @@ -96,6 +106,9 @@ public class Post extends Posting { @Column(name = "vote_count") private int voteCount; + @Transient + private boolean isSaved = false; + public Post() { } @@ -222,6 +235,20 @@ public void setVoteCount(Integer voteCount) { this.voteCount = voteCount != null ? voteCount : 0; } + @JsonIgnore + public Set getSavedPosts() { + return savedPosts; + } + + @JsonProperty("isSaved") + public boolean getIsSaved() { + return isSaved; + } + + public void setIsSaved(boolean isSaved) { + this.isSaved = isSaved; + } + /** * Helper method to extract the course a Post belongs to, which is found in different locations based on the Post's context * diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/Posting.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Posting.java index ad60a1130916..4ae7c6fe800e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/Posting.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/Posting.java @@ -18,6 +18,7 @@ import com.fasterxml.jackson.annotation.JsonIncludeProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.DomainObject; import de.tum.cit.aet.artemis.core.domain.User; @@ -118,4 +119,6 @@ public void setAuthorRole(UserRole authorRole) { @Transient public abstract Course getCoursePostingBelongsTo(); + + public abstract Conversation getConversation(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/PostingType.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/PostingType.java new file mode 100644 index 000000000000..aedad4d1b55c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/PostingType.java @@ -0,0 +1,23 @@ +package de.tum.cit.aet.artemis.communication.domain; + +import java.util.Arrays; + +public enum PostingType { + + POST((short) 0), ANSWER((short) 1); + + private final short databaseKey; + + PostingType(short databaseKey) { + this.databaseKey = databaseKey; + } + + public short getDatabaseKey() { + return databaseKey; + } + + public static PostingType fromDatabaseKey(short databaseKey) { + return Arrays.stream(PostingType.values()).filter(type -> type.getDatabaseKey() == databaseKey).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown database key: " + databaseKey)); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPost.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPost.java new file mode 100644 index 000000000000..88d1c79b96c4 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPost.java @@ -0,0 +1,83 @@ +package de.tum.cit.aet.artemis.communication.domain; + +import java.time.ZonedDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import de.tum.cit.aet.artemis.core.domain.DomainObject; +import de.tum.cit.aet.artemis.core.domain.User; + +@Entity +@Table(name = "saved_post") +public class SavedPost extends DomainObject { + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "post_id", nullable = false) + private Long postId; + + @Enumerated + @Column(name = "post_type", nullable = false) + private PostingType postType; + + @Enumerated + @Column(name = "status", nullable = false) + private SavedPostStatus status; + + @Column(name = "completed_at") + private ZonedDateTime completedAt; + + public SavedPost() { + } + + public SavedPost(User user, Long postId, PostingType postType, SavedPostStatus status, ZonedDateTime completedAt) { + this.user = user; + this.postId = postId; + this.postType = postType; + this.status = status; + this.completedAt = completedAt; + } + + public Long getPostId() { + return postId; + } + + public void setPostId(Long postId) { + this.postId = postId; + } + + public void setStatus(SavedPostStatus status) { + this.status = status; + } + + public User getUser() { + return user; + } + + public SavedPostStatus getStatus() { + return status; + } + + public void setCompletedAt(ZonedDateTime completedAt) { + this.completedAt = completedAt; + } + + public void setPostType(PostingType postType) { + this.postType = postType; + } + + public PostingType getPostType() { + return postType; + } + + public ZonedDateTime getCompletedAt() { + return completedAt; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPostStatus.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPostStatus.java new file mode 100644 index 000000000000..b2fd523277be --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/SavedPostStatus.java @@ -0,0 +1,23 @@ +package de.tum.cit.aet.artemis.communication.domain; + +import java.util.Arrays; + +public enum SavedPostStatus { + + IN_PROGRESS((short) 0), COMPLETED((short) 1), ARCHIVED((short) 2); + + private final short databaseKey; + + SavedPostStatus(short databaseKey) { + this.databaseKey = databaseKey; + } + + public short getDatabaseKey() { + return databaseKey; + } + + public static SavedPostStatus fromDatabaseKey(short databaseKey) { + return Arrays.stream(SavedPostStatus.values()).filter(type -> type.getDatabaseKey() == databaseKey).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown database key: " + databaseKey)); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/AuthorDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/AuthorDTO.java new file mode 100644 index 000000000000..8feb1dd746c1 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/AuthorDTO.java @@ -0,0 +1,13 @@ +package de.tum.cit.aet.artemis.communication.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.core.domain.User; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record AuthorDTO(Long id, String name, String imageUrl) { + + public AuthorDTO(User user) { + this(user.getId(), user.getName(), user.getImageUrl()); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingConversationDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingConversationDTO.java new file mode 100644 index 000000000000..9c93cd4d47e5 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingConversationDTO.java @@ -0,0 +1,34 @@ +package de.tum.cit.aet.artemis.communication.dto; + +import de.tum.cit.aet.artemis.communication.domain.ConversationType; +import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; +import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; +import de.tum.cit.aet.artemis.communication.domain.conversation.GroupChat; + +public record PostingConversationDTO(Long id, String title, ConversationType type) { + + public PostingConversationDTO(Conversation conversation) { + this(conversation.getId(), determineTitle(conversation), determineType(conversation)); + } + + private static String determineTitle(Conversation conversation) { + if (conversation instanceof Channel) { + return ((Channel) conversation).getName(); + } + else if (conversation instanceof GroupChat) { + return ((GroupChat) conversation).getName(); + } + else { + return "Chat"; + } + } + + private static ConversationType determineType(Conversation conversation) { + if (conversation instanceof Channel) { + return ConversationType.CHANNEL; + } + else { + return ConversationType.DIRECT; + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingDTO.java new file mode 100644 index 000000000000..a394237230c0 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/PostingDTO.java @@ -0,0 +1,40 @@ +package de.tum.cit.aet.artemis.communication.dto; + +import java.time.ZonedDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.communication.domain.AnswerPost; +import de.tum.cit.aet.artemis.communication.domain.Posting; +import de.tum.cit.aet.artemis.communication.domain.PostingType; +import de.tum.cit.aet.artemis.communication.domain.UserRole; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record PostingDTO(Long id, AuthorDTO author, UserRole role, ZonedDateTime creationDate, ZonedDateTime updatedDate, String content, boolean isSaved, short savedPostStatus, + List reactions, PostingConversationDTO conversation, short postingType, Long referencePostId) { + + public PostingDTO(Posting post, boolean isSaved, short savedPostStatus) { + this(post.getId(), new AuthorDTO(post.getAuthor()), post.getAuthorRole(), post.getCreationDate(), post.getUpdatedDate(), post.getContent(), isSaved, savedPostStatus, + post.getReactions().stream().map(ReactionDTO::new).toList(), new PostingConversationDTO(post.getConversation()), getSavedPostType(post).getDatabaseKey(), + getReferencePostId(post)); + } + + static PostingType getSavedPostType(Posting posting) { + if (posting instanceof AnswerPost) { + return PostingType.ANSWER; + } + else { + return PostingType.POST; + } + } + + static Long getReferencePostId(Posting posting) { + if (posting instanceof AnswerPost) { + return ((AnswerPost) posting).getPost().getId(); + } + else { + return posting.getId(); + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/dto/ReactionDTO.java b/src/main/java/de/tum/cit/aet/artemis/communication/dto/ReactionDTO.java new file mode 100644 index 000000000000..a81a00799ece --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/dto/ReactionDTO.java @@ -0,0 +1,12 @@ +package de.tum.cit.aet.artemis.communication.dto; + +import java.time.ZonedDateTime; + +import de.tum.cit.aet.artemis.communication.domain.Reaction; + +public record ReactionDTO(Long id, AuthorDTO user, ZonedDateTime creationDate, String emojiId) { + + public ReactionDTO(Reaction reaction) { + this(reaction.getId(), new AuthorDTO(reaction.getUser()), reaction.getCreationDate(), reaction.getEmojiId()); + } +} 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 db61138b3a73..43ac921f2d8a 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 @@ -31,5 +31,12 @@ default AnswerPost findAnswerMessageByIdElseThrow(Long answerPostId) { return getValueElseThrow(findById(answerPostId).filter(answerPost -> answerPost.getPost().getConversation() != null), answerPostId); } + @NotNull + default AnswerPost findAnswerPostOrMessageByIdElseThrow(Long answerPostId) { + return getValueElseThrow(findById(answerPostId), answerPostId); + } + long countAnswerPostsByPostIdIn(List postIds); + + List findByIdIn(List idList); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java index 2952c5213432..16c5be3aedc8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/ConversationMessageRepository.java @@ -114,6 +114,7 @@ private PageImpl findPostsWithSpecification(Pageable pageable, Specificati LEFT JOIN FETCH p.conversation LEFT JOIN FETCH p.reactions LEFT JOIN FETCH p.tags + LEFT JOIN FETCH p.savedPosts LEFT JOIN FETCH p.answers a LEFT JOIN FETCH a.reactions LEFT JOIN FETCH a.post 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 aacfbc33d179..1ea95f1d6657 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 @@ -49,4 +49,6 @@ default Post findPostOrMessagePostByIdElseThrow(Long postId) throws EntityNotFou List findAllByConversationId(Long conversationId); List findAllByCourseId(Long courseId); + + List findByIdIn(List idList); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/repository/SavedPostRepository.java b/src/main/java/de/tum/cit/aet/artemis/communication/repository/SavedPostRepository.java new file mode 100644 index 000000000000..e0a00a5896aa --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/repository/SavedPostRepository.java @@ -0,0 +1,143 @@ +package de.tum.cit.aet.artemis.communication.repository; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import de.tum.cit.aet.artemis.communication.domain.PostingType; +import de.tum.cit.aet.artemis.communication.domain.SavedPost; +import de.tum.cit.aet.artemis.communication.domain.SavedPostStatus; +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; + +@Profile(PROFILE_CORE) +@Repository +@CacheConfig(cacheNames = "savedPosts") +public interface SavedPostRepository extends ArtemisJpaRepository { + + /*** + * Get the amount of saved posts of a user. E.g. for checking if maximum allowed bookmarks are reached. + * Cached by user id. + * + * @param userId to query for + * + * @return The amount of bookmarks of the user. + */ + @Cacheable(key = "'saved_post_count_' + #userId") + Long countByUserId(Long userId); + + /*** + * Get a single saved post by user id, connected post/answer post id and posting type. Not cached. + * + * @param userId of the bookmark + * @param postId of the bookmark + * @param postType of the bookmark + * + * @return The saved post if exists, null otherwise. + */ + SavedPost findSavedPostByUserIdAndPostIdAndPostType(Long userId, Long postId, PostingType postType); + + /*** + * Query all post ids that a user has saved by a certain posting type. Cached by user id and post type. + * + * @param userId of the bookmarks + * @param postType of the bookmarks + * + * @return List of ids of posts/answer posts of the given user, filtered by the given post type. + */ + @Query(""" + SELECT s.postId + FROM SavedPost s + WHERE s.user.id = :userId AND s.postType = :postType + """) + @Cacheable(key = "'saved_post_type_' + #postType.getDatabaseKey() + '_' + #userId") + List findSavedPostIdsByUserIdAndPostType(@Param("userId") Long userId, @Param("postType") PostingType postType); + + /*** + * Query all saved posts of a user by status. E.g. for displaying the saved posts. Cached by user id and status. + * + * @param userId of the bookmarks + * @param status of the bookmarks + * + * @return List of saved posts of the given user, filtered by the given status. + */ + @Cacheable(key = "'saved_post_status_' + #status.getDatabaseKey() + '_' + #userId") + List findSavedPostsByUserIdAndStatusOrderByCompletedAtDescIdDesc(Long userId, SavedPostStatus status); + + /*** + * Query all SavedPosts for a certain user. Not cached. + * + * @param userId of the bookmarks + * + * @return List of saved posts of the given user. + */ + List findSavedPostsByUserId(Long userId); + + /*** + * Query to get all SavedPosts that are completed before a certain cutoff date. E.g. for cleanup. + * + * @param cutoffDate the date from where to query the saved posts + * + * @return List of saved posts which were completed before the given date + */ + List findByCompletedAtBefore(ZonedDateTime cutoffDate); + + /*** + * Saving should clear the cached queries for a given user + * The value "saved_post_type_0" represents a post, given by the enum {{@link PostingType}} + * The value "saved_post_type_1" represents an answer post, given by the enum {{@link PostingType}} + * The value "saved_post_status_0" represents in progress, given by the enum {{@link SavedPostStatus}} + * The value "saved_post_status_1" represents in completed, given by the enum {{@link SavedPostStatus}} + * The value "saved_post_status_2" represents in archived, given by the enum {{@link SavedPostStatus}} + * + * @param savedPost to create / update + * + * @return Newly stored saved post + */ + @Caching(evict = { @CacheEvict(key = "'saved_post_type_0_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_type_1_' + #savedPost.user.id"), + @CacheEvict(key = "'saved_post_status_0_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_status_1_' + #savedPost.user.id"), + @CacheEvict(key = "'saved_post_status_2_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_count_' + #savedPost.user.id"), }) + @Override + S save(S savedPost); + + /*** + * Deleting should clear the cached queries for a given user + * The value "saved_post_type_0" represents a post, given by the enum {{@link PostingType}} + * The value "saved_post_type_1" represents an answer post, given by the enum {{@link PostingType}} + * The value "saved_post_status_0" represents in progress, given by the enum {{@link SavedPostStatus}} + * The value "saved_post_status_1" represents in completed, given by the enum {{@link SavedPostStatus}} + * The value "saved_post_status_2" represents in archived, given by the enum {{@link SavedPostStatus}} + * + * @param savedPost to delete + */ + @Caching(evict = { @CacheEvict(key = "'saved_post_type_0_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_type_1_' + #savedPost.user.id"), + @CacheEvict(key = "'saved_post_status_0_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_status_1_' + #savedPost.user.id"), + @CacheEvict(key = "'saved_post_status_2_' + #savedPost.user.id"), @CacheEvict(key = "'saved_post_count_' + #savedPost.user.id"), }) + @Override + void delete(SavedPost savedPost); + + /*** + * The value "sp.postType = 0" represents a post, given by the enum {{@link PostingType}} + * + * @return List of saved posts that do not have a post entity connected to them + */ + @Query("SELECT sp FROM SavedPost sp " + "LEFT JOIN Post p ON sp.postId = p.id " + "WHERE sp.postType = 0 AND p.id IS NULL") + List findOrphanedPostReferences(); + + /*** + * The value "sp.postType = 1" represents an answer post, given by the enum {{@link PostingType}} + * + * @return List of saved posts that do not have an answer post entity connected to them + */ + @Query("SELECT sp FROM SavedPost sp " + "LEFT JOIN AnswerPost ap ON sp.postId = ap.id " + "WHERE sp.postType = 1 AND ap.id IS NULL") + List findOrphanedAnswerReferences(); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/AnswerMessageService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/AnswerMessageService.java index fa370edc0737..f7645c202f63 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/AnswerMessageService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/AnswerMessageService.java @@ -21,6 +21,7 @@ import de.tum.cit.aet.artemis.communication.repository.ConversationMessageRepository; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; import de.tum.cit.aet.artemis.communication.repository.PostRepository; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.communication.repository.conversation.ConversationRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ConversationService; import de.tum.cit.aet.artemis.communication.service.conversation.auth.ChannelAuthorizationService; @@ -59,10 +60,11 @@ public class AnswerMessageService extends PostingService { @SuppressWarnings("PMD.ExcessiveParameterList") public AnswerMessageService(SingleUserNotificationService singleUserNotificationService, CourseRepository courseRepository, AuthorizationCheckService authorizationCheckService, UserRepository userRepository, AnswerPostRepository answerPostRepository, ConversationMessageRepository conversationMessageRepository, - ConversationService conversationService, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, + ConversationService conversationService, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, SavedPostRepository savedPostRepository, WebsocketMessagingService websocketMessagingService, ConversationParticipantRepository conversationParticipantRepository, ChannelAuthorizationService channelAuthorizationService, PostRepository postRepository, ConversationRepository conversationRepository) { - super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository); + super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository, + savedPostRepository); this.answerPostRepository = answerPostRepository; this.conversationMessageRepository = conversationMessageRepository; this.conversationService = conversationService; @@ -205,6 +207,7 @@ public void deleteAnswerMessageById(Long courseId, Long answerMessageId) { // delete answerPostRepository.deleteById(answerMessageId); + preparePostForBroadcast(updatedMessage); broadcastForPost(new PostDTO(updatedMessage, MetisCrudAction.UPDATE), course.getId(), null, null); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java index a54058431b76..06f9409bddc0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/ConversationMessagingService.java @@ -40,6 +40,7 @@ import de.tum.cit.aet.artemis.communication.dto.PostDTO; import de.tum.cit.aet.artemis.communication.repository.ConversationMessageRepository; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.communication.repository.SingleUserNotificationRepository; import de.tum.cit.aet.artemis.communication.service.conversation.ConversationService; import de.tum.cit.aet.artemis.communication.service.conversation.auth.ChannelAuthorizationService; @@ -78,9 +79,10 @@ public class ConversationMessagingService extends PostingService { protected ConversationMessagingService(CourseRepository courseRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, ConversationMessageRepository conversationMessageRepository, AuthorizationCheckService authorizationCheckService, WebsocketMessagingService websocketMessagingService, UserRepository userRepository, ConversationService conversationService, ConversationParticipantRepository conversationParticipantRepository, - ConversationNotificationService conversationNotificationService, ChannelAuthorizationService channelAuthorizationService, + ConversationNotificationService conversationNotificationService, ChannelAuthorizationService channelAuthorizationService, SavedPostRepository savedPostRepository, GroupNotificationService groupNotificationService, SingleUserNotificationRepository singleUserNotificationRepository) { - super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository); + super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository, + savedPostRepository); this.conversationService = conversationService; this.conversationMessageRepository = conversationMessageRepository; this.conversationNotificationService = conversationNotificationService; @@ -154,6 +156,7 @@ public void notifyAboutMessageCreation(CreatedConversationMessage createdConvers Set recipientSummaries; ConversationNotification notification = conversationNotificationService.createNotification(createdMessage, conversation, course, createdConversationMessage.mentionedUsers()); + preparePostForBroadcast(createdMessage); PostDTO postDTO = new PostDTO(createdMessage, MetisCrudAction.CREATE, notification); createdMessage.getConversation().hideDetails(); if (createdConversationMessage.completeConversation() instanceof Channel channel && channel.getIsCourseWide()) { @@ -284,7 +287,6 @@ private Set filterNotificationRecipients(User author, Conversation convers public Page getMessages(Pageable pageable, @Valid PostContextFilterDTO postContextFilter, User requestingUser, Long courseId) { conversationService.isMemberOrCreateForCourseWideElseThrow(postContextFilter.conversationId(), requestingUser, Optional.of(ZonedDateTime.now())); - // The following query loads posts, answerPosts and reactions to avoid too many database calls (due to eager references) Page conversationPosts = conversationMessageRepository.findMessages(postContextFilter, pageable, requestingUser.getId()); setAuthorRoleOfPostings(conversationPosts.getContent(), courseId); @@ -342,6 +344,7 @@ public Post updateMessage(Long courseId, Long postId, Post messagePost) { updatedPost.setConversation(conversation); // emit a post update via websocket + preparePostForBroadcast(updatedPost); broadcastForPost(new PostDTO(updatedPost, MetisCrudAction.UPDATE), course.getId(), null, null); return updatedPost; @@ -369,7 +372,7 @@ public void deleteMessageById(Long courseId, Long postId) { conversation = conversationService.getConversationById(conversation.getId()); conversationService.notifyAllConversationMembersAboutUpdate(conversation); - + preparePostForBroadcast(post); broadcastForPost(new PostDTO(post, MetisCrudAction.DELETE), course.getId(), null, null); } @@ -400,6 +403,8 @@ public Post changeDisplayPriority(Long courseId, Long postId, DisplayPriority di Post updatedMessage = conversationMessageRepository.save(message); message.getConversation().hideDetails(); + preparePostForBroadcast(message); + preparePostForBroadcast(updatedMessage); broadcastForPost(new PostDTO(message, MetisCrudAction.UPDATE), course.getId(), null, null); return updatedMessage; } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/PostingService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/PostingService.java index f3a01dab6ba6..786675a38986 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/PostingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/PostingService.java @@ -21,6 +21,7 @@ import de.tum.cit.aet.artemis.communication.domain.ConversationNotificationRecipientSummary; import de.tum.cit.aet.artemis.communication.domain.Post; import de.tum.cit.aet.artemis.communication.domain.Posting; +import de.tum.cit.aet.artemis.communication.domain.PostingType; import de.tum.cit.aet.artemis.communication.domain.UserRole; import de.tum.cit.aet.artemis.communication.domain.conversation.Channel; import de.tum.cit.aet.artemis.communication.domain.conversation.Conversation; @@ -29,6 +30,7 @@ import de.tum.cit.aet.artemis.communication.dto.MetisCrudAction; import de.tum.cit.aet.artemis.communication.dto.PostDTO; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.CourseInformationSharingConfiguration; import de.tum.cit.aet.artemis.core.domain.User; @@ -53,6 +55,8 @@ public abstract class PostingService { protected final LectureRepository lectureRepository; + protected final SavedPostRepository savedPostRepository; + protected final ConversationParticipantRepository conversationParticipantRepository; protected final AuthorizationCheckService authorizationCheckService; @@ -65,7 +69,7 @@ public abstract class PostingService { protected PostingService(CourseRepository courseRepository, UserRepository userRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, AuthorizationCheckService authorizationCheckService, WebsocketMessagingService websocketMessagingService, - ConversationParticipantRepository conversationParticipantRepository) { + ConversationParticipantRepository conversationParticipantRepository, SavedPostRepository savedPostRepository) { this.courseRepository = courseRepository; this.userRepository = userRepository; this.exerciseRepository = exerciseRepository; @@ -73,6 +77,28 @@ protected PostingService(CourseRepository courseRepository, UserRepository userR this.authorizationCheckService = authorizationCheckService; this.websocketMessagingService = websocketMessagingService; this.conversationParticipantRepository = conversationParticipantRepository; + this.savedPostRepository = savedPostRepository; + } + + /** + * Helper method to prepare the post included in the websocket message and initiate the broadcasting + * + * @param post post that should be broadcast + */ + public void preparePostForBroadcast(Post post) { + try { + var user = userRepository.getUser(); + var savedPostIds = savedPostRepository.findSavedPostIdsByUserIdAndPostType(user.getId(), PostingType.POST); + post.setIsSaved(savedPostIds.contains(post.getId())); + var savedAnswerIds = savedPostRepository.findSavedPostIdsByUserIdAndPostType(user.getId(), PostingType.ANSWER); + post.getAnswers().forEach(answer -> answer.setIsSaved(savedAnswerIds.contains(answer.getId()))); + } + catch (Exception e) { + post.setIsSaved(false); + post.getAnswers().forEach(answer -> { + answer.setIsSaved(false); + }); + } } /** @@ -89,6 +115,7 @@ protected void preparePostAndBroadcast(AnswerPost updatedAnswerPost, Course cour // we need to remove the existing AnswerPost (based on unchanged id in updatedAnswerPost) and add the updatedAnswerPost afterwards updatedPost.removeAnswerPost(updatedAnswerPost); updatedPost.addAnswerPost(updatedAnswerPost); + preparePostForBroadcast(updatedPost); broadcastForPost(new PostDTO(updatedPost, MetisCrudAction.UPDATE, notification), course.getId(), null, null); } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/ReactionService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/ReactionService.java index a1b9b2b71ec8..562e30dfd48b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/service/ReactionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/ReactionService.java @@ -138,6 +138,7 @@ public void deleteReactionById(Long reactionId, Long courseId) { updatedPost.removeAnswerPost(updatedAnswerPost); updatedPost.addAnswerPost(updatedAnswerPost); } + plagiarismPostService.preparePostForBroadcast(updatedPost); plagiarismPostService.broadcastForPost(new PostDTO(updatedPost, MetisCrudAction.UPDATE), course.getId(), null, null); reactionRepository.deleteById(reactionId); } @@ -201,6 +202,7 @@ private Reaction createReactionForPost(Reaction reaction, Post posting, User use Post updatedPost = postRepository.save(post); updatedPost.setConversation(post.getConversation()); + plagiarismPostService.preparePostForBroadcast(post); plagiarismPostService.broadcastForPost(new PostDTO(post, MetisCrudAction.UPDATE), course.getId(), null, null); return savedReaction; } diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostScheduleService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostScheduleService.java new file mode 100644 index 000000000000..25e5922031f5 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostScheduleService.java @@ -0,0 +1,68 @@ +package de.tum.cit.aet.artemis.communication.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_SCHEDULING; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.communication.domain.SavedPost; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; + +@Service +@Profile(PROFILE_SCHEDULING) +public class SavedPostScheduleService { + + private static final int DAYS_UNTIL_ARCHIVED_ARE_DELETED = 100; + + private static final Logger log = LoggerFactory.getLogger(SavedPostScheduleService.class); + + private final SavedPostRepository savedPostRepository; + + public SavedPostScheduleService(SavedPostRepository savedPostRepository) { + this.savedPostRepository = savedPostRepository; + } + + /** + * Cleans up all archived/completed posts that are older than specified cutoff date + */ + @Scheduled(cron = "0 0 0 * * *") + public void cleanupArchivedSavedPosts() { + ZonedDateTime cutoffDate = ZonedDateTime.now().minusDays(DAYS_UNTIL_ARCHIVED_ARE_DELETED); + + List oldPosts = savedPostRepository.findByCompletedAtBefore(cutoffDate); + if (!oldPosts.isEmpty()) { + savedPostRepository.deleteAll(oldPosts); + log.info("Deleted {} archived saved posts", oldPosts.size()); + } + } + + /** + * Cleans up all saved posts where the post entity does not exist anymore + */ + @Scheduled(cron = "0 0 0 * * *") + public void cleanupOrphanedSavedPosts() { + List orphanedPosts = savedPostRepository.findOrphanedPostReferences(); + if (!orphanedPosts.isEmpty()) { + savedPostRepository.deleteAll(orphanedPosts); + log.info("Deleted {} orphaned post references", orphanedPosts.size()); + } + } + + /** + * Cleans up all saved posts where the answer post entity does not exist anymore + */ + @Scheduled(cron = "0 0 0 * * *") + public void cleanupOrphanedSavedAnswerPosts() { + List orphanedPosts = savedPostRepository.findOrphanedAnswerReferences(); + if (!orphanedPosts.isEmpty()) { + savedPostRepository.deleteAll(orphanedPosts); + log.info("Deleted {} orphaned answer post references", orphanedPosts.size()); + } + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostService.java b/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostService.java new file mode 100644 index 000000000000..14172c6d3d05 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/service/SavedPostService.java @@ -0,0 +1,123 @@ +package de.tum.cit.aet.artemis.communication.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.time.ZonedDateTime; +import java.util.List; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.communication.domain.Post; +import de.tum.cit.aet.artemis.communication.domain.Posting; +import de.tum.cit.aet.artemis.communication.domain.PostingType; +import de.tum.cit.aet.artemis.communication.domain.SavedPost; +import de.tum.cit.aet.artemis.communication.domain.SavedPostStatus; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; +import de.tum.cit.aet.artemis.core.repository.UserRepository; + +@Profile(PROFILE_CORE) +@Service +public class SavedPostService { + + private static final int MAX_SAVED_POSTS_PER_USER = 100; + + private final SavedPostRepository savedPostRepository; + + private final UserRepository userRepository; + + public SavedPostService(SavedPostRepository savedPostRepository, UserRepository userRepository) { + this.savedPostRepository = savedPostRepository; + this.userRepository = userRepository; + } + + /** + * Saves a post for the currently logged-in user, if post is already saved it returns + * + * @param post post to save + */ + public void savePostForCurrentUser(Posting post) { + var existingSavedPost = this.getSavedPostForCurrentUser(post); + + if (existingSavedPost != null) { + return; + } + + PostingType type = post instanceof Post ? PostingType.POST : PostingType.ANSWER; + var author = userRepository.getUser(); + var savedPost = new SavedPost(author, post.getId(), type, SavedPostStatus.IN_PROGRESS, null); + savedPostRepository.save(savedPost); + } + + /** + * Removes a bookmark of a post for the currently logged-in user, if post is not saved it returns + * + * @param post post to remove from bookmarks + * @return false if the saved post was not found, true if post was found and deleted + */ + public boolean removeSavedPostForCurrentUser(Posting post) { + var existingSavedPost = this.getSavedPostForCurrentUser(post); + + if (existingSavedPost == null) { + return false; + } + + savedPostRepository.delete(existingSavedPost); + + return true; + } + + /** + * Updates the status of a bookmark, will return if no bookmark is present + * + * @param post post to change status + * @param status status to change towards + */ + public void updateStatusOfSavedPostForCurrentUser(Posting post, SavedPostStatus status) { + var existingSavedPost = this.getSavedPostForCurrentUser(post); + + if (existingSavedPost == null) { + return; + } + + existingSavedPost.setStatus(status); + existingSavedPost.setCompletedAt(status == SavedPostStatus.IN_PROGRESS ? null : ZonedDateTime.now()); + savedPostRepository.save(existingSavedPost); + } + + /** + * Retrieve the saved posts for a given status + * + * @param status status to query + * @return a list of all saved posts of the current user with the given status + */ + public List getSavedPostsForCurrentUserByStatus(SavedPostStatus status) { + var currentUser = userRepository.getUser(); + + return savedPostRepository.findSavedPostsByUserIdAndStatusOrderByCompletedAtDescIdDesc(currentUser.getId(), status); + } + + /** + * Checks if maximum amount of saved posts limit is reached + * + * @return true if max saved post it reached, false otherwise + */ + public boolean isMaximumSavedPostsReached() { + var currentUser = userRepository.getUser(); + + return MAX_SAVED_POSTS_PER_USER <= savedPostRepository.countByUserId(currentUser.getId()); + } + + /** + * Helper method to retrieve a bookmark for the current user + * + * @param post post to search bookmark for + * @return The saved post for the given posting if present + */ + private SavedPost getSavedPostForCurrentUser(Posting post) { + PostingType type = post instanceof Post ? PostingType.POST : PostingType.ANSWER; + var author = userRepository.getUser(); + + return savedPostRepository.findSavedPostByUserIdAndPostIdAndPostType(author.getId(), post.getId(), type); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java index bfa04d53cc5f..75c68fbec7a1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/ConversationMessageResource.java @@ -132,6 +132,8 @@ else if (postContextFilter.courseWideChannelIds() != null) { if (post.getConversation() != null) { post.getConversation().hideDetails(); } + + conversationMessagingService.preparePostForBroadcast(post); }); final var headers = PaginationUtil.generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), coursePosts); logDuration(coursePosts.getContent(), principal, timeNanoStart); diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/web/SavedPostResource.java b/src/main/java/de/tum/cit/aet/artemis/communication/web/SavedPostResource.java new file mode 100644 index 000000000000..a7c6ba9c7faa --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/communication/web/SavedPostResource.java @@ -0,0 +1,218 @@ +package de.tum.cit.aet.artemis.communication.web; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import de.tum.cit.aet.artemis.communication.domain.AnswerPost; +import de.tum.cit.aet.artemis.communication.domain.Post; +import de.tum.cit.aet.artemis.communication.domain.Posting; +import de.tum.cit.aet.artemis.communication.domain.PostingType; +import de.tum.cit.aet.artemis.communication.domain.SavedPost; +import de.tum.cit.aet.artemis.communication.domain.SavedPostStatus; +import de.tum.cit.aet.artemis.communication.dto.PostingDTO; +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.service.SavedPostService; +import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; +import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; +import de.tum.cit.aet.artemis.core.util.TimeLogUtil; + +/** + * REST controller for managing Message Posts. + */ +@Profile(PROFILE_CORE) +@RestController +@RequestMapping("api/") +public class SavedPostResource { + + private static final Logger log = LoggerFactory.getLogger(SavedPostResource.class); + + public static final String ENTITY_NAME = "savedPost"; + + private final SavedPostService savedPostService; + + private final PostRepository postRepository; + + private final AnswerPostRepository answerPostRepository; + + public SavedPostResource(SavedPostService savedPostService, PostRepository postRepository, AnswerPostRepository answerPostRepository) { + this.savedPostService = savedPostService; + this.postRepository = postRepository; + this.answerPostRepository = answerPostRepository; + } + + /** + * GET /saved-posts/{courseId}/{status} : Get saved posts of course with specific status + * + * @param courseId id of course to filter posts + * @param status saved post status (progress, completed, archived) + * @return ResponseEntity with status 200 (Success) if course id and status are ok, + * or with status 400 (Bad Request) if the checks on type or post validity fail + */ + @GetMapping("saved-posts/{courseId}/{status}") + @EnforceAtLeastStudent + public ResponseEntity> getSavedPosts(@PathVariable Long courseId, @PathVariable short status) { + log.debug("GET getSavedPosts invoked for course {} and status {}", courseId, status); + long start = System.nanoTime(); + + SavedPostStatus savedPostStatus; + try { + savedPostStatus = SavedPostStatus.fromDatabaseKey(status); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("The provided post status could not be found.", ENTITY_NAME, "savedPostStatusDoesNotExist"); + } + + var savedPosts = savedPostService.getSavedPostsForCurrentUserByStatus(savedPostStatus); + + List posts = postRepository.findByIdIn(savedPosts.stream().filter(savedPost -> savedPost.getPostType() == PostingType.POST).map(SavedPost::getPostId).toList()) + .stream().filter(post -> Objects.equals(post.getCoursePostingBelongsTo().getId(), courseId)).toList(); + List answerPosts = answerPostRepository + .findByIdIn(savedPosts.stream().filter(savedPost -> savedPost.getPostType() == PostingType.ANSWER).map(SavedPost::getPostId).toList()).stream() + .filter(post -> Objects.equals(post.getCoursePostingBelongsTo().getId(), courseId)).toList(); + List postingList = new ArrayList<>(); + + for (SavedPost savedPost : savedPosts) { + Optional posting; + if (savedPost.getPostType() == PostingType.ANSWER) { + posting = answerPosts.stream().filter(answerPost -> answerPost.getId().equals(savedPost.getPostId())).findFirst(); + } + else { + posting = posts.stream().filter(post -> post.getId().equals(savedPost.getPostId())).findFirst(); + } + if (posting.isPresent()) { + postingList.add(new PostingDTO((Posting) posting.get(), true, savedPost.getStatus().getDatabaseKey())); + } + } + + log.info("getSavedPosts took {}", TimeLogUtil.formatDurationFrom(start)); + return new ResponseEntity<>(postingList, null, HttpStatus.OK); + } + + /** + * POST /saved-posts/{postId}/{type} : Create a new saved post + * + * @param postId post to save + * @param type post type (post, answer) + * @return ResponseEntity with status 201 (Created) if successfully saved post, + * or with status 400 (Bad Request) if the checks on type or post validity fail + */ + @PostMapping("saved-posts/{postId}/{type}") + @EnforceAtLeastStudent + public ResponseEntity savePost(@PathVariable Long postId, @PathVariable short type) { + log.debug("POST savePost invoked for post {}", postId); + long start = System.nanoTime(); + + if (savedPostService.isMaximumSavedPostsReached()) { + throw new BadRequestAlertException("The maximum amount of saved posts was reached.", ENTITY_NAME, "savedPostMaxReached"); + } + + var post = retrievePostingElseThrow(postId, type); + + this.savedPostService.savePostForCurrentUser(post); + + log.info("savePost took {}", TimeLogUtil.formatDurationFrom(start)); + return new ResponseEntity<>(null, null, HttpStatus.CREATED); + } + + /** + * DELETE /saved-posts/{postId}/{type} : Remove a saved post + * + * @param postId post to save + * @param type post type (post, answer) + * @return ResponseEntity with status 204 (No content) if successfully deleted post, + * or with status 400 (Bad Request) if the checks on type or post validity fail + */ + @DeleteMapping("saved-posts/{postId}/{type}") + @EnforceAtLeastStudent + public ResponseEntity deleteSavedPost(@PathVariable Long postId, @PathVariable short type) { + log.debug("DELETE deletePost invoked for post {}", postId); + long start = System.nanoTime(); + + var post = retrievePostingElseThrow(postId, type); + + if (!this.savedPostService.removeSavedPostForCurrentUser(post)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You are not allowed to delete this bookmark."); + } + + log.info("deletePost took {}", TimeLogUtil.formatDurationFrom(start)); + return new ResponseEntity<>(null, null, HttpStatus.NO_CONTENT); + } + + /** + * PUT /saved-posts/{postId}/{type} : Update the status of a saved post + * + * @param postId post to save + * @param type post type (post, answer) + * @param status saved post status (progress, answer) + * @return ResponseEntity with status 200 (Success) if successfully updated saved post status, + * or with status 400 (Bad Request) if the checks on type or post validity fail + */ + @PutMapping("saved-posts/{postId}/{type}") + @EnforceAtLeastStudent + public ResponseEntity putSavedPost(@PathVariable Long postId, @PathVariable short type, @RequestParam(name = "status") short status) { + log.debug("DELETE putSavedPost invoked for post {}", postId); + long start = System.nanoTime(); + + var post = retrievePostingElseThrow(postId, type); + + SavedPostStatus savedPostStatus; + try { + savedPostStatus = SavedPostStatus.fromDatabaseKey(status); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("The provided post status could not be found.", ENTITY_NAME, "savedPostStatusDoesNotExist"); + } + + this.savedPostService.updateStatusOfSavedPostForCurrentUser(post, savedPostStatus); + + log.info("putSavedPost took {}", TimeLogUtil.formatDurationFrom(start)); + return new ResponseEntity<>(null, null, HttpStatus.OK); + } + + private Posting retrievePostingElseThrow(long postId, short type) throws BadRequestAlertException { + PostingType postingType; + + try { + postingType = PostingType.fromDatabaseKey(type); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("The provided post type could not be found.", ENTITY_NAME, "savedPostTypeDoesNotExist"); + } + + Posting post; + try { + if (postingType == PostingType.POST) { + post = postRepository.findPostOrMessagePostByIdElseThrow(postId); + } + else { + post = answerPostRepository.findAnswerPostOrMessageByIdElseThrow(postId); + } + } + catch (EntityNotFoundException e) { + throw new BadRequestAlertException("The provided post could not be found.", ENTITY_NAME, "savedPostIdDoesNotExist"); + } + + return post; + } +} 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 6498340f3bc2..2ef2478cf295 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 @@ -40,6 +40,7 @@ import de.tum.cit.aet.artemis.atlas.domain.competency.CompetencyProgress; import de.tum.cit.aet.artemis.atlas.domain.competency.LearningPath; +import de.tum.cit.aet.artemis.communication.domain.SavedPost; import de.tum.cit.aet.artemis.communication.domain.push_notification.PushNotificationDeviceConfiguration; import de.tum.cit.aet.artemis.core.config.Constants; import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; @@ -180,6 +181,9 @@ public class User extends AbstractAuditingEntity implements Participant { @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) private Set guidedTourSettings = new HashSet<>(); + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true) + private Set savedPosts = new HashSet<>(); + @ManyToMany @JoinTable(name = "jhi_user_authority", joinColumns = { @JoinColumn(name = "user_id", referencedColumnName = "id") }, inverseJoinColumns = { @JoinColumn(name = "authority_name", referencedColumnName = "name") }) diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java index d06ecfec87af..31937ac6a5b6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/user/UserService.java @@ -39,6 +39,8 @@ import org.springframework.util.StringUtils; import de.tum.cit.aet.artemis.atlas.repository.ScienceEventRepository; +import de.tum.cit.aet.artemis.communication.domain.SavedPost; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.core.domain.Authority; import de.tum.cit.aet.artemis.core.domain.GuidedTourSetting; import de.tum.cit.aet.artemis.core.domain.User; @@ -113,11 +115,13 @@ public class UserService { private final ParticipationVcsAccessTokenService participationVCSAccessTokenService; + private final SavedPostRepository savedPostRepository; + public UserService(UserCreationService userCreationService, UserRepository userRepository, AuthorityService authorityService, AuthorityRepository authorityRepository, CacheManager cacheManager, Optional ldapUserService, GuidedTourSettingsRepository guidedTourSettingsRepository, PasswordService passwordService, Optional optionalVcsUserManagementService, Optional optionalCIUserManagementService, InstanceMessageSendService instanceMessageSendService, FileService fileService, ScienceEventRepository scienceEventRepository, - ParticipationVcsAccessTokenService participationVCSAccessTokenService) { + ParticipationVcsAccessTokenService participationVCSAccessTokenService, SavedPostRepository savedPostRepository) { this.userCreationService = userCreationService; this.userRepository = userRepository; this.authorityService = authorityService; @@ -132,6 +136,7 @@ public UserService(UserCreationService userCreationService, UserRepository userR this.fileService = fileService; this.scienceEventRepository = scienceEventRepository; this.participationVCSAccessTokenService = participationVCSAccessTokenService; + this.savedPostRepository = savedPostRepository; } /** @@ -493,6 +498,12 @@ protected void anonymizeUser(User user) { user.setActivated(false); user.setGroups(Collections.emptySet()); + List savedPostsOfUser = savedPostRepository.findSavedPostsByUserId(user.getId()); + + if (!savedPostsOfUser.isEmpty()) { + savedPostRepository.deleteAll(savedPostsOfUser); + } + userRepository.save(user); clearUserCaches(user); userRepository.flush(); diff --git a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismAnswerPostService.java b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismAnswerPostService.java index 0c3ff1beb754..ec2f97befb23 100644 --- a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismAnswerPostService.java +++ b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismAnswerPostService.java @@ -15,6 +15,7 @@ import de.tum.cit.aet.artemis.communication.repository.AnswerPostRepository; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; import de.tum.cit.aet.artemis.communication.repository.PostRepository; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.communication.service.PostingService; import de.tum.cit.aet.artemis.communication.service.WebsocketMessagingService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -40,8 +41,9 @@ public class PlagiarismAnswerPostService extends PostingService { protected PlagiarismAnswerPostService(CourseRepository courseRepository, AuthorizationCheckService authorizationCheckService, UserRepository userRepository, AnswerPostRepository answerPostRepository, PostRepository postRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, - WebsocketMessagingService websocketMessagingService, ConversationParticipantRepository conversationParticipantRepository) { - super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository); + WebsocketMessagingService websocketMessagingService, ConversationParticipantRepository conversationParticipantRepository, SavedPostRepository savedPostRepository) { + super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository, + savedPostRepository); this.answerPostRepository = answerPostRepository; this.postRepository = postRepository; } @@ -164,7 +166,7 @@ public void deleteAnswerPostById(Long courseId, Long answerPostId) { // delete answerPostRepository.deleteById(answerPostId); - + preparePostForBroadcast(post); broadcastForPost(new PostDTO(post, MetisCrudAction.UPDATE), course.getId(), null, null); } diff --git a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismPostService.java b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismPostService.java index fc5bda5882c3..a1034b96e335 100644 --- a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismPostService.java +++ b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/PlagiarismPostService.java @@ -16,6 +16,7 @@ import de.tum.cit.aet.artemis.communication.dto.PostDTO; import de.tum.cit.aet.artemis.communication.repository.ConversationParticipantRepository; import de.tum.cit.aet.artemis.communication.repository.PostRepository; +import de.tum.cit.aet.artemis.communication.repository.SavedPostRepository; import de.tum.cit.aet.artemis.communication.service.PostingService; import de.tum.cit.aet.artemis.communication.service.WebsocketMessagingService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -42,9 +43,11 @@ public class PlagiarismPostService extends PostingService { private final PlagiarismCaseService plagiarismCaseService; protected PlagiarismPostService(CourseRepository courseRepository, AuthorizationCheckService authorizationCheckService, UserRepository userRepository, - PostRepository postRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, WebsocketMessagingService websocketMessagingService, - PlagiarismCaseService plagiarismCaseService, PlagiarismCaseRepository plagiarismCaseRepository, ConversationParticipantRepository conversationParticipantRepository) { - super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository); + SavedPostRepository savedPostRepository, PostRepository postRepository, ExerciseRepository exerciseRepository, LectureRepository lectureRepository, + WebsocketMessagingService websocketMessagingService, PlagiarismCaseService plagiarismCaseService, PlagiarismCaseRepository plagiarismCaseRepository, + ConversationParticipantRepository conversationParticipantRepository) { + super(courseRepository, userRepository, exerciseRepository, lectureRepository, authorizationCheckService, websocketMessagingService, conversationParticipantRepository, + savedPostRepository); this.postRepository = postRepository; this.plagiarismCaseRepository = plagiarismCaseRepository; this.plagiarismCaseService = plagiarismCaseService; @@ -132,6 +135,7 @@ public Post updatePost(Long courseId, Long postId, Post post) { Post updatedPost = postRepository.save(existingPost); + preparePostForBroadcast(updatedPost); broadcastForPost(new PostDTO(updatedPost, MetisCrudAction.UPDATE), course.getId(), null, null); return updatedPost; } @@ -184,6 +188,7 @@ public void deletePostById(Long courseId, Long postId) { // delete postRepository.deleteById(postId); + preparePostForBroadcast(post); broadcastForPost(new PostDTO(post, MetisCrudAction.DELETE), course.getId(), null, null); } diff --git a/src/main/resources/config/liquibase/changelog/20241101121000_changelog.xml b/src/main/resources/config/liquibase/changelog/20241101121000_changelog.xml new file mode 100644 index 000000000000..b8e12b77c118 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241101121000_changelog.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 6a06b398b783..e29f09657055 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -33,6 +33,7 @@ + diff --git a/src/main/webapp/app/core/user/account.model.ts b/src/main/webapp/app/core/user/account.model.ts index ad1d36a2a18b..37f98ded0405 100644 --- a/src/main/webapp/app/core/user/account.model.ts +++ b/src/main/webapp/app/core/user/account.model.ts @@ -11,7 +11,7 @@ export class Account { public lastName?: string; public langKey?: string; public imageUrl?: string; - public guidedTourSettings: GuidedTourSetting[]; + public guidedTourSettings?: GuidedTourSetting[]; constructor( activated?: boolean, diff --git a/src/main/webapp/app/entities/metis/conversation/conversation.model.ts b/src/main/webapp/app/entities/metis/conversation/conversation.model.ts index ddb146f12d1f..841a8a5f53de 100644 --- a/src/main/webapp/app/entities/metis/conversation/conversation.model.ts +++ b/src/main/webapp/app/entities/metis/conversation/conversation.model.ts @@ -23,6 +23,7 @@ export abstract class Conversation implements BaseEntity { public creator?: User; public creationDate?: dayjs.Dayjs; public lastMessageDate?: dayjs.Dayjs; + public title?: string; protected constructor(type: ConversationType) { this.type = type; diff --git a/src/main/webapp/app/entities/metis/post.model.ts b/src/main/webapp/app/entities/metis/post.model.ts index 60adfe3c64c0..39950a74af0b 100644 --- a/src/main/webapp/app/entities/metis/post.model.ts +++ b/src/main/webapp/app/entities/metis/post.model.ts @@ -2,7 +2,6 @@ import { AnswerPost } from 'app/entities/metis/answer-post.model'; import { Posting } from 'app/entities/metis/posting.model'; import { DisplayPriority } from 'app/shared/metis/metis.util'; import { PlagiarismCase } from 'app/exercises/shared/plagiarism/types/PlagiarismCase'; -import { Conversation } from 'app/entities/metis/conversation/conversation.model'; export class Post extends Posting { public title?: string; @@ -10,7 +9,6 @@ export class Post extends Posting { public answers?: AnswerPost[]; public tags?: string[]; public plagiarismCase?: PlagiarismCase; - public conversation?: Conversation; public displayPriority?: DisplayPriority; public resolved?: boolean; public isConsecutive?: boolean = false; diff --git a/src/main/webapp/app/entities/metis/posting.model.ts b/src/main/webapp/app/entities/metis/posting.model.ts index fc7d5d206095..dff35c800654 100644 --- a/src/main/webapp/app/entities/metis/posting.model.ts +++ b/src/main/webapp/app/entities/metis/posting.model.ts @@ -3,13 +3,58 @@ import { User } from 'app/core/user/user.model'; import dayjs from 'dayjs/esm'; import { Reaction } from 'app/entities/metis/reaction.model'; import { UserRole } from 'app/shared/metis/metis.util'; +import { Conversation } from 'app/entities/metis/conversation/conversation.model'; + +export enum SavedPostStatus { + PROGRESS = 0, + COMPLETED = 1, + ARCHIVED = 2, +} + +export enum SavedPostStatusMap { + PROGRESS = 'progress', + COMPLETED = 'completed', + ARCHIVED = 'archived', +} + +export enum PostingType { + POST = 0, + ANSWER = 1, +} export abstract class Posting implements BaseEntity { public id?: number; + public referencePostId?: number; public author?: User; public authorRole?: UserRole; public creationDate?: dayjs.Dayjs; public updatedDate?: dayjs.Dayjs; public content?: string; + public isSaved?: boolean; + public savedPostStatus?: number; + public postingType?: number; public reactions?: Reaction[]; + public conversation?: Conversation; + + public static mapToStatus(map: SavedPostStatusMap) { + switch (map) { + case SavedPostStatusMap.COMPLETED: + return SavedPostStatus.COMPLETED; + case SavedPostStatusMap.ARCHIVED: + return SavedPostStatus.ARCHIVED; + default: + return SavedPostStatus.PROGRESS; + } + } + + public static statusToMap(status: SavedPostStatus) { + switch (status) { + case SavedPostStatus.COMPLETED: + return SavedPostStatusMap.COMPLETED; + case SavedPostStatus.ARCHIVED: + return SavedPostStatusMap.ARCHIVED; + default: + return SavedPostStatusMap.PROGRESS; + } + } } diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html index 1e7b44fa6fb7..6b2bac7d8ada 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.html +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.html @@ -38,7 +38,7 @@ [sidebarItemAlwaysShow]="DEFAULT_SHOW_ALWAYS" [collapseState]="DEFAULT_COLLAPSE_STATE" [inCommunication]="true" - [reEmitNonDistinctSidebarEvents]="isMobile" + [reEmitNonDistinctSidebarEvents]="true" /> @if (course && !activeConversation && isCodeOfConductPresented) { @@ -57,9 +57,15 @@ (openThread)="postInThread = $event" [course]="course" [searchbarCollapsed]="channelSearchCollapsed" + [focusPostId]="focusPostId" + [openThreadOnFocus]="openThreadOnFocus" /> } @else { - + @if (selectedSavedPostStatus === null) { + + } @else { + + } }
diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts index 1b194b218bd3..f65878c794e1 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, EventEmitter, HostListener, OnDestroy, OnInit, ViewChild, ViewEncapsulation, inject } from '@angular/core'; +import { ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, OnDestroy, OnInit, ViewChild, ViewEncapsulation, inject } from '@angular/core'; import { ConversationDTO } from 'app/entities/metis/conversation/conversation.model'; import { Post } from 'app/entities/metis/post.model'; import { ActivatedRoute, Router } from '@angular/router'; @@ -10,7 +10,21 @@ import { ChannelDTO, ChannelSubType, getAsChannelDTO } from 'app/entities/metis/ import { MetisService } from 'app/shared/metis/metis.service'; import { Course, isMessagingEnabled } from 'app/entities/course.model'; import { PageType, SortDirection } from 'app/shared/metis/metis.util'; -import { faBan, faComment, faComments, faFile, faFilter, faGraduationCap, faHeart, faList, faMessage, faPlus, faSearch, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { + faBan, + faBookmark, + faComment, + faComments, + faFile, + faFilter, + faGraduationCap, + faHeart, + faList, + faMessage, + faPlus, + faSearch, + faTimes, +} from '@fortawesome/free-solid-svg-icons'; import { ButtonType } from 'app/shared/components/button.component'; import { CourseWideSearchComponent, CourseWideSearchConfig } from 'app/overview/course-conversations/course-wide-search/course-wide-search.component'; import { AccordionGroups, ChannelAccordionShowAdd, ChannelTypeIcons, CollapseState, SidebarCardElement, SidebarData, SidebarItemShowAlways } from 'app/types/sidebar'; @@ -25,6 +39,7 @@ import { ChannelsCreateDialogComponent } from 'app/overview/course-conversations import { CourseSidebarService } from 'app/overview/course-sidebar.service'; import { LayoutService } from 'app/shared/breakpoints/layout.service'; import { CustomBreakpointNames } from 'app/shared/breakpoints/breakpoints.service'; +import { Posting, PostingType, SavedPostStatus, SavedPostStatusMap } from 'app/entities/metis/posting.model'; const DEFAULT_CHANNEL_GROUPS: AccordionGroups = { favoriteChannels: { entityData: [] }, @@ -33,6 +48,7 @@ const DEFAULT_CHANNEL_GROUPS: AccordionGroups = { lectureChannels: { entityData: [] }, examChannels: { entityData: [] }, hiddenChannels: { entityData: [] }, + savedPosts: { entityData: [] }, }; const CHANNEL_TYPE_SHOW_ADD_OPTION: ChannelAccordionShowAdd = { @@ -44,6 +60,7 @@ const CHANNEL_TYPE_SHOW_ADD_OPTION: ChannelAccordionShowAdd = { favoriteChannels: false, lectureChannels: true, hiddenChannels: false, + savedPosts: false, }; const CHANNEL_TYPE_ICON: ChannelTypeIcons = { @@ -55,6 +72,7 @@ const CHANNEL_TYPE_ICON: ChannelTypeIcons = { favoriteChannels: faHeart, lectureChannels: faFile, hiddenChannels: faBan, + savedPosts: faBookmark, }; const DEFAULT_COLLAPSE_STATE: CollapseState = { @@ -66,6 +84,7 @@ const DEFAULT_COLLAPSE_STATE: CollapseState = { favoriteChannels: false, lectureChannels: true, hiddenChannels: true, + savedPosts: true, }; const DEFAULT_SHOW_ALWAYS: SidebarItemShowAlways = { @@ -77,6 +96,7 @@ const DEFAULT_SHOW_ALWAYS: SidebarItemShowAlways = { favoriteChannels: true, lectureChannels: false, hiddenChannels: false, + savedPosts: true, }; @Component({ @@ -110,6 +130,9 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { isProduction = true; isTestServer = false; isMobile = false; + focusPostId: number | undefined = undefined; + openThreadOnFocus = false; + selectedSavedPostStatus: null | SavedPostStatus = null; readonly CHANNEL_TYPE_SHOW_ADD_OPTION = CHANNEL_TYPE_SHOW_ADD_OPTION; readonly CHANNEL_TYPE_ICON = CHANNEL_TYPE_ICON; @@ -140,6 +163,7 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { private courseSidebarService: CourseSidebarService = inject(CourseSidebarService); private layoutService: LayoutService = inject(LayoutService); + private changeDetector: ChangeDetectorRef = inject(ChangeDetectorRef); constructor( private router: Router, @@ -251,9 +275,23 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { subscribeToQueryParameter() { this.activatedRoute.queryParams.pipe(take(1), takeUntil(this.ngUnsubscribe)).subscribe((queryParams) => { if (queryParams.conversationId) { - this.metisConversationService.setActiveConversation(Number(queryParams.conversationId)); - - this.closeSidebarOnMobile(); + if ( + isNaN(Number(queryParams.conversationId)) && + Object.values(SavedPostStatusMap) + .map((s) => s.toString()) + .includes(queryParams.conversationId) + ) { + this.selectedSavedPostStatus = Posting.mapToStatus(queryParams.conversationId as SavedPostStatusMap); + } else { + this.metisConversationService.setActiveConversation(Number(queryParams.conversationId)); + this.closeSidebarOnMobile(); + } + } + if (queryParams.focusPostId) { + this.focusPostId = Number(queryParams.focusPostId); + } + if (queryParams.openThreadOnFocus) { + this.openThreadOnFocus = queryParams.openThreadOnFocus; } if (queryParams.messageId) { this.postInThread = { id: Number(queryParams.messageId) } as Post; @@ -265,11 +303,22 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { }); } + onNavigateToPost(post: Posting) { + if (post.referencePostId === undefined || post.conversation?.id === undefined) { + return; + } + + this.focusPostId = post.referencePostId; + this.openThreadOnFocus = (post.postingType as PostingType) === PostingType.ANSWER; + this.metisConversationService.setActiveConversation(post.conversation!.id!); + this.changeDetector.detectChanges(); + } + updateQueryParameters() { this.router.navigate([], { relativeTo: this.activatedRoute, queryParams: { - conversationId: this.activeConversation?.id, + conversationId: this.activeConversation?.id ?? (this.selectedSavedPostStatus !== null ? Posting.statusToMap(this.selectedSavedPostStatus) : undefined), }, replaceUrl: true, }); @@ -348,6 +397,7 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { this.courseSidebarService.openSidebar(); } } + this.selectedSavedPostStatus = null; this.metisConversationService.setActiveConversation(undefined); this.activeConversation = undefined; this.updateQueryParameters(); @@ -377,9 +427,29 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { }; } - onConversationSelected(conversationId: number) { + onConversationSelected(conversationId: number | string) { this.closeSidebarOnMobile(); - this.metisConversationService.setActiveConversation(conversationId); + this.focusPostId = undefined; + this.openThreadOnFocus = false; + if (typeof conversationId === 'string') { + if ( + Object.values(SavedPostStatusMap) + .map((s) => s.toString()) + .includes(conversationId) + ) { + this.selectedSavedPostStatus = Posting.mapToStatus(conversationId as SavedPostStatusMap); + this.postInThread = undefined; + this.metisConversationService.setActiveConversation(undefined); + this.activeConversation = undefined; + this.updateQueryParameters(); + this.metisService.resetCachedPosts(); + this.changeDetector.detectChanges(); + } + } else { + conversationId = +conversationId; + this.selectedSavedPostStatus = null; + this.metisConversationService.setActiveConversation(conversationId); + } } toggleSidebar() { diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts b/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts index 0646118f577f..10d2cea22583 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.module.ts @@ -31,6 +31,8 @@ import { CourseConversationsCodeOfConductComponent } from 'app/overview/course-c import { CourseWideSearchComponent } from 'app/overview/course-conversations/course-wide-search/course-wide-search.component'; import { ArtemisSidebarModule } from 'app/shared/sidebar/sidebar.module'; import { ProfilePictureComponent } from 'app/shared/profile-picture/profile-picture.component'; +import { SavedPostsComponent } from 'app/overview/course-conversations/saved-posts/saved-posts.component'; +import { PostingSummaryComponent } from 'app/overview/course-conversations/posting-summary/posting-summary.component'; const routes: Routes = [ { @@ -79,6 +81,8 @@ const routes: Routes = [ OneToOneChatCreateDialogComponent, GroupChatCreateDialogComponent, CourseWideSearchComponent, + SavedPostsComponent, + PostingSummaryComponent, ], }) export class CourseConversationsModule {} diff --git a/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html b/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html index 1295be3edf63..c9641f8aa287 100644 --- a/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html +++ b/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html @@ -72,7 +72,7 @@

}
-