diff --git a/build.gradle b/build.gradle index 41cc4db0a54c..7fc5d67e206a 100644 --- a/build.gradle +++ b/build.gradle @@ -192,7 +192,7 @@ jacocoTestCoverageVerification { counter = "CLASS" value = "MISSEDCOUNT" // TODO: in the future the following value should become less than 10 - maximum = 29 + maximum = 30 } } } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java index 996b2bd3a739..5200c1285a47 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/ExerciseRepository.java @@ -70,7 +70,7 @@ public interface ExerciseRepository extends JpaRepository { FROM Exercise e WHERE e.course.id = :courseId """) - Set findAllExercisesByCourseId(@Param("courseId") Long courseId); + Set findAllExercisesByCourseId(@Param("courseId") long courseId); @Query(""" SELECT e diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ParticipantScoreRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ParticipantScoreRepository.java index 1a2809c8cb2b..30e1f9c5b588 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/ParticipantScoreRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/ParticipantScoreRepository.java @@ -53,6 +53,13 @@ public interface ParticipantScoreRepository extends JpaRepository findAllByExercise(Exercise exercise); + @Query(""" + SELECT p + FROM ParticipantScore p + WHERE p.exercise.course.id = :courseId + """) + List findAllByCourseId(@Param("courseId") long courseId); + @Query(""" SELECT AVG(p.lastScore) FROM ParticipantScore p diff --git a/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java index 8dba33849922..dfcb9c91ff32 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java @@ -606,6 +606,30 @@ default User getUser() { return unwrapOptionalUser(user, currentUserLogin); } + /** + * Finds user id by login + * + * @param login the login of the user to search + * @return optional of the user id if it exists, empty otherwise + */ + @Query(""" + SELECT u.id + FROM User u + WHERE u.login = :login + """) + Optional findIdByLogin(@Param("login") String login); + + /** + * Get the user id of the currently logged-in user + * + * @return the user id of the currently logged-in user + */ + default long getUserIdElseThrow() { + String currentUserLogin = getCurrentUserLogin(); + Optional userId = findIdByLogin(currentUserLogin); + return userId.orElseThrow(() -> new EntityNotFoundException("User: " + currentUserLogin)); + } + /** * Retrieve a user by its login, or else throw exception * diff --git a/src/main/java/de/tum/in/www1/artemis/repository/metrics/ExerciseMetricsRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/metrics/ExerciseMetricsRepository.java new file mode 100644 index 000000000000..4bf8cd72df54 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/repository/metrics/ExerciseMetricsRepository.java @@ -0,0 +1,99 @@ +package de.tum.in.www1.artemis.repository.metrics; + +import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE; + +import java.util.Set; + +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import de.tum.in.www1.artemis.domain.Exercise; +import de.tum.in.www1.artemis.web.rest.dto.metrics.ExerciseInformationDTO; +import de.tum.in.www1.artemis.web.rest.dto.metrics.ResourceTimestampDTO; +import de.tum.in.www1.artemis.web.rest.dto.metrics.ScoreDTO; + +/** + * Spring Data JPA repository to fetch exercise related metrics. + */ +@Profile(PROFILE_CORE) +@Repository +public interface ExerciseMetricsRepository extends JpaRepository { + + /** + * Get the exercise information for all exercises in a course. + * + * @param courseId the id of the course + * @return the exercise information for all exercises in the course + */ + @Query(""" + SELECT new de.tum.in.www1.artemis.web.rest.dto.metrics.ExerciseInformationDTO(e.id, e.shortName, e.title, COALESCE(e.startDate, e.releaseDate), e.dueDate, e.maxPoints, e.class) + FROM Exercise e + WHERE e.course.id = :courseId + """) + Set findAllExerciseInformationByCourseId(@Param("courseId") long courseId); + + @Query(""" + SELECT new de.tum.in.www1.artemis.web.rest.dto.metrics.ScoreDTO(p.exercise.id, AVG(COALESCE(p.lastScore, 0))) + FROM ParticipantScore p + WHERE p.exercise.id IN :exerciseIds + GROUP BY p.exercise.id + """) + Set findAverageScore(@Param("exerciseIds") Set exerciseIds); + + @Query(""" + SELECT new de.tum.in.www1.artemis.web.rest.dto.metrics.ScoreDTO(s.exercise.id, CAST(COALESCE(s.lastRatedScore, s.lastScore, 0) AS DOUBLE)) + FROM StudentScore s + WHERE s.exercise.id IN :exerciseIds + AND s.user.id = :userId + """) + Set findScore(@Param("exerciseIds") Set exerciseIds, @Param("userId") long userId); + + /** + * Get the latest submission dates for a user in a set of exercises. + * + * @param exerciseIds the ids of the exercises + * @param userId the id of the user + * @return the latest submission dates for the user in the exercises + */ + @Query(""" + SELECT new de.tum.in.www1.artemis.web.rest.dto.metrics.ResourceTimestampDTO(e.id, s.submissionDate) + FROM Submission s + LEFT JOIN StudentParticipation p ON s.participation.id = p.id + LEFT JOIN p.exercise e + LEFT JOIN p.team t + LEFT JOIN t.students u + WHERE e.id IN :exerciseIds + AND s.submissionDate = ( + SELECT MAX(s2.submissionDate) + FROM Submission s2 + WHERE s2.participation.id = s.participation.id + AND s2.submitted = TRUE + ) + AND (p.student.id = :userId OR u.id = :userId) + """) + Set findLatestSubmissionDatesForUser(@Param("exerciseIds") Set exerciseIds, @Param("userId") long userId); + + /** + * Get the latest submission dates for a set of exercises. + * + * @param exerciseIds the ids of the exercises + * @return the latest submission dates for the exercises + */ + @Query(""" + SELECT new de.tum.in.www1.artemis.web.rest.dto.metrics.ResourceTimestampDTO(e.id, s.submissionDate) + FROM Submission s + LEFT JOIN StudentParticipation p ON s.participation.id = p.id + LEFT JOIN p.exercise e + WHERE e.id IN :exerciseIds + AND s.submissionDate = ( + SELECT MAX(s2.submissionDate) + FROM Submission s2 + WHERE s2.participation.id = s.participation.id + AND s2.submitted = TRUE + ) + """) + Set findLatestSubmissionDates(@Param("exerciseIds") Set exerciseIds); +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/metrics/MetricsService.java b/src/main/java/de/tum/in/www1/artemis/service/metrics/MetricsService.java new file mode 100644 index 000000000000..3a73473d12ec --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/metrics/MetricsService.java @@ -0,0 +1,80 @@ +package de.tum.in.www1.artemis.service.metrics; + +import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE; +import static de.tum.in.www1.artemis.service.util.ZonedDateTimeUtil.toRelativeTime; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.averagingDouble; +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.toMap; + +import java.time.ZonedDateTime; +import java.util.function.Predicate; +import java.util.function.ToDoubleFunction; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.in.www1.artemis.repository.metrics.ExerciseMetricsRepository; +import de.tum.in.www1.artemis.web.rest.dto.metrics.ExerciseInformationDTO; +import de.tum.in.www1.artemis.web.rest.dto.metrics.ExerciseStudentMetricsDTO; +import de.tum.in.www1.artemis.web.rest.dto.metrics.ResourceTimestampDTO; +import de.tum.in.www1.artemis.web.rest.dto.metrics.ScoreDTO; +import de.tum.in.www1.artemis.web.rest.dto.metrics.StudentMetricsDTO; + +/** + * Service class to access metrics regarding students' learning progress. + */ +@Profile(PROFILE_CORE) +@Service +public class MetricsService { + + private final ExerciseMetricsRepository exerciseMetricsRepository; + + public MetricsService(ExerciseMetricsRepository exerciseMetricsRepository) { + this.exerciseMetricsRepository = exerciseMetricsRepository; + } + + /** + * Get the metrics for a student in a course. + * + * @param userId the id of the student + * @param courseId the id of the course + * @return the metrics for the student in the course + */ + public StudentMetricsDTO getStudentCourseMetrics(long userId, long courseId) { + final var exerciseMetricsDTO = getStudentExerciseMetrics(userId, courseId); + return new StudentMetricsDTO(exerciseMetricsDTO); + } + + /** + * Get the exercise metrics for a student in a course. + * + * @param userId the id of the student + * @param courseId the id of the course + * @return the metrics for the student in the course + */ + public ExerciseStudentMetricsDTO getStudentExerciseMetrics(long userId, long courseId) { + final var exerciseInfo = exerciseMetricsRepository.findAllExerciseInformationByCourseId(courseId); + // generate map and remove exercises that are not yet started + final Predicate started = e -> e.start() != null && e.start().isBefore(ZonedDateTime.now()); + final var exerciseInfoMap = exerciseInfo.stream().filter(started).collect(toMap(ExerciseInformationDTO::id, identity())); + + final var exerciseIds = exerciseInfoMap.keySet(); + + final var averageScore = exerciseMetricsRepository.findAverageScore(exerciseIds); + final var averageScoreMap = averageScore.stream().collect(toMap(ScoreDTO::exerciseId, ScoreDTO::score)); + + final var score = exerciseMetricsRepository.findScore(exerciseIds, userId); + final var scoreMap = score.stream().collect(toMap(ScoreDTO::exerciseId, ScoreDTO::score)); + + final var latestSubmissions = exerciseMetricsRepository.findLatestSubmissionDates(exerciseIds); + final ToDoubleFunction relativeTime = dto -> toRelativeTime(exerciseInfoMap.get(dto.id()).start(), exerciseInfoMap.get(dto.id()).due(), + dto.timestamp()); + final var averageLatestSubmissionMap = latestSubmissions.stream().collect(groupingBy(ResourceTimestampDTO::id, averagingDouble(relativeTime))); + + final var latestSubmissionOfUser = exerciseMetricsRepository.findLatestSubmissionDatesForUser(exerciseIds, userId); + final var latestSubmissionMap = latestSubmissionOfUser.stream().collect(toMap(ResourceTimestampDTO::id, relativeTime::applyAsDouble)); + + return new ExerciseStudentMetricsDTO(exerciseInfoMap, averageScoreMap, scoreMap, averageLatestSubmissionMap, latestSubmissionMap); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/scheduled/ParticipantScoreScheduleService.java b/src/main/java/de/tum/in/www1/artemis/service/scheduled/ParticipantScoreScheduleService.java index 54d9d87d59df..2e1402f4a24e 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/scheduled/ParticipantScoreScheduleService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/scheduled/ParticipantScoreScheduleService.java @@ -11,6 +11,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; import jakarta.annotation.PreDestroy; import jakarta.validation.constraints.NotNull; @@ -267,7 +268,7 @@ private void executeTask(Long exerciseId, Long participantId, Instant resultLast teamScoreRepository.deleteAllByTeamId(participantId); return; } - participantScore = teamScoreRepository.findByExercise_IdAndTeam_Id(exerciseId, participantId).map(score -> score); + participantScore = teamScoreRepository.findByExercise_IdAndTeam_Id(exerciseId, participantId).map(Function.identity()); } else { // Fetch the student and its score for the given exercise @@ -278,7 +279,7 @@ private void executeTask(Long exerciseId, Long participantId, Instant resultLast studentScoreRepository.deleteAllByUserId(participantId); return; } - participantScore = studentScoreRepository.findByExercise_IdAndUser_Id(exerciseId, participantId).map(score -> score); + participantScore = studentScoreRepository.findByExercise_IdAndUser_Id(exerciseId, participantId).map(Function.identity()); } if (participantScore.isPresent()) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/util/ZonedDateTimeUtil.java b/src/main/java/de/tum/in/www1/artemis/service/util/ZonedDateTimeUtil.java new file mode 100644 index 000000000000..9057963710e8 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/util/ZonedDateTimeUtil.java @@ -0,0 +1,29 @@ +package de.tum.in.www1.artemis.service.util; + +import java.time.ZonedDateTime; + +import jakarta.validation.constraints.NotNull; + +public class ZonedDateTimeUtil { + + /** + * Private constructor to prevent instantiation. + */ + private ZonedDateTimeUtil() { + throw new IllegalStateException("Utility class"); + } + + /** + * Get the relative time of a ZonedDateTime object compared to an origin and a unit ZonedDateTime object in percent. + *

+ * Example: origin = 0:00, unit = 10:00, target = 2:30 => 25% + * + * @param origin the origin ZonedDateTime object + * @param unit the unit ZonedDateTime object + * @param target the target ZonedDateTime object + * @return the relative time of the target ZonedDateTime object compared to the origin and unit ZonedDateTime objects + */ + public static double toRelativeTime(@NotNull ZonedDateTime origin, @NotNull ZonedDateTime unit, @NotNull ZonedDateTime target) { + return 100.0 * (target.toEpochSecond() - origin.toEpochSecond()) / (unit.toEpochSecond() - origin.toEpochSecond()); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/MetricsResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/MetricsResource.java new file mode 100644 index 000000000000..e29bb6197e47 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/MetricsResource.java @@ -0,0 +1,49 @@ +package de.tum.in.www1.artemis.web.rest; + +import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.in.www1.artemis.repository.UserRepository; +import de.tum.in.www1.artemis.security.annotations.enforceRoleInCourse.EnforceAtLeastStudentInCourse; +import de.tum.in.www1.artemis.service.metrics.MetricsService; +import de.tum.in.www1.artemis.web.rest.dto.metrics.StudentMetricsDTO; + +@Profile(PROFILE_CORE) +@RestController +@RequestMapping("api/metrics/") +public class MetricsResource { + + private static final Logger log = LoggerFactory.getLogger(MetricsResource.class); + + private final MetricsService metricsService; + + private final UserRepository userRepository; + + public MetricsResource(MetricsService metricsService, UserRepository userRepository) { + this.metricsService = metricsService; + this.userRepository = userRepository; + } + + /** + * GET course/:courseId/student : Gets the metrics of a course for the logged-in user. + * + * @param courseId the id of the course from which to get the metrics + * @return the ResponseEntity with status 200 (OK) with body the student metrics for the course + */ + @GetMapping("course/{courseId}/student") + @EnforceAtLeastStudentInCourse + public ResponseEntity getCourseMetricsForUser(@PathVariable long courseId) { + final var userId = userRepository.getUserIdElseThrow(); // won't throw exception since EnforceRoleInResource checks existence of user + log.debug("REST request to get the metrics for the user with id {} in the course with id {}", userId, courseId); + final var studentMetrics = metricsService.getStudentCourseMetrics(userId, courseId); + return ResponseEntity.ok(studentMetrics); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/ExerciseInformationDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/ExerciseInformationDTO.java new file mode 100644 index 000000000000..4700372ca823 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/ExerciseInformationDTO.java @@ -0,0 +1,52 @@ +package de.tum.in.www1.artemis.web.rest.dto.metrics; + +import java.time.ZonedDateTime; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.in.www1.artemis.domain.Exercise; + +/** + * DTO for exercise information. + * + * @param id the id of the exercise + * @param shortName shortTitle the short title of the exercise + * @param title title the title of the exercise + * @param start the start date of the exercise + * @param due the due date of the exercise + * @param maxPoints the maximum achievable points of the exercise + * @param type the type of the exercise + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record ExerciseInformationDTO(long id, String shortName, String title, ZonedDateTime start, ZonedDateTime due, Double maxPoints, Class type) { + + /** + * Create a new ExerciseInformationDTO from an exercise. + * + * @param exercise the exercise to create the DTO from + * @return the new ExerciseInformationDTO + */ + public static ExerciseInformationDTO of(E exercise) { + var startDate = exercise.getStartDate(); + if (startDate == null) { + startDate = exercise.getReleaseDate(); + } + return new ExerciseInformationDTO(exercise.getId(), exercise.getShortName(), exercise.getTitle(), startDate, exercise.getDueDate(), exercise.getMaxPoints(), + exercise.getClass()); + } + + @Override + public boolean equals(Object other) { + if (other == null) { + return false; + } + + if (other instanceof ExerciseInformationDTO otherDTO) { + // Compare all fields for equality, for dates the isEqual method is used to compare the date and time + return id == otherDTO.id && shortName.equals(otherDTO.shortName) && title.equals(otherDTO.title) && start.isEqual(otherDTO.start) && due.isEqual(otherDTO.due) + && maxPoints.equals(otherDTO.maxPoints) && type.equals(otherDTO.type); + } + + return false; + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/ExerciseStudentMetricsDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/ExerciseStudentMetricsDTO.java new file mode 100644 index 000000000000..9feac66eb403 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/ExerciseStudentMetricsDTO.java @@ -0,0 +1,19 @@ +package de.tum.in.www1.artemis.web.rest.dto.metrics; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * DTO for exercise student metrics. + * + * @param exerciseInformation the information about the exercises + * @param averageScore the average score of the students in the exercises + * @param score the score of the student in the exercises + * @param averageLatestSubmission the average relative time of the latest submissions in the exercises + * @param latestSubmission the relative time of the latest submission of the students in the exercises + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record ExerciseStudentMetricsDTO(Map exerciseInformation, Map averageScore, Map score, + Map averageLatestSubmission, Map latestSubmission) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/ResourceTimestampDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/ResourceTimestampDTO.java new file mode 100644 index 000000000000..17722ddfb0a1 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/ResourceTimestampDTO.java @@ -0,0 +1,12 @@ +package de.tum.in.www1.artemis.web.rest.dto.metrics; + +import java.time.ZonedDateTime; + +/** + * A DTO representing a timestamp associated to a resource. + * + * @param id the id of the resource + * @param timestamp the timestamp associated to the resource + */ +public record ResourceTimestampDTO(long id, ZonedDateTime timestamp) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/ScoreDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/ScoreDTO.java new file mode 100644 index 000000000000..245f97d8d3dc --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/ScoreDTO.java @@ -0,0 +1,10 @@ +package de.tum.in.www1.artemis.web.rest.dto.metrics; + +/** + * DTO for the score for an exercise. + * + * @param exerciseId the id of the exercise + * @param score the score of the exercise + */ +public record ScoreDTO(long exerciseId, Double score) { +} diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/StudentMetricsDTO.java b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/StudentMetricsDTO.java new file mode 100644 index 000000000000..e30d5894336d --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/dto/metrics/StudentMetricsDTO.java @@ -0,0 +1,6 @@ +package de.tum.in.www1.artemis.web.rest.dto.metrics; + +import jakarta.validation.constraints.NotNull; + +public record StudentMetricsDTO(@NotNull ExerciseStudentMetricsDTO exerciseMetrics) { +} diff --git a/src/test/java/de/tum/in/www1/artemis/AbstractArtemisIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/AbstractArtemisIntegrationTest.java index d0c00b45cf2b..b33fe6cac88d 100644 --- a/src/test/java/de/tum/in/www1/artemis/AbstractArtemisIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/AbstractArtemisIntegrationTest.java @@ -23,9 +23,12 @@ import org.springframework.mail.javamail.JavaMailSender; import org.springframework.test.context.junit.jupiter.SpringExtension; +import de.tum.in.www1.artemis.course.CourseUtilService; import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.VcsRepositoryUri; import de.tum.in.www1.artemis.exercise.programming.MockDelegate; +import de.tum.in.www1.artemis.repository.ExerciseRepository; +import de.tum.in.www1.artemis.repository.ResultRepository; import de.tum.in.www1.artemis.service.FileService; import de.tum.in.www1.artemis.service.ModelingSubmissionService; import de.tum.in.www1.artemis.service.TextBlockService; @@ -53,6 +56,7 @@ import de.tum.in.www1.artemis.service.scheduled.ScheduleService; import de.tum.in.www1.artemis.service.scheduled.cache.quiz.QuizScheduleService; import de.tum.in.www1.artemis.user.UserFactory; +import de.tum.in.www1.artemis.user.UserUtilService; import de.tum.in.www1.artemis.util.HibernateQueryInterceptor; import de.tum.in.www1.artemis.util.QueryCountAssert; import de.tum.in.www1.artemis.util.RequestUtilService; @@ -163,6 +167,18 @@ public abstract class AbstractArtemisIntegrationTest implements MockDelegate { @Autowired protected HibernateQueryInterceptor queryInterceptor; + @Autowired + protected UserUtilService userUtilService; + + @Autowired + protected CourseUtilService courseUtilService; + + @Autowired + protected ExerciseRepository exerciseRepository; + + @Autowired + protected ResultRepository resultRepository; + @BeforeEach void mockMailService() { doNothing().when(javaMailSender).send(any(MimeMessage.class)); diff --git a/src/test/java/de/tum/in/www1/artemis/MetricsIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/MetricsIntegrationTest.java new file mode 100644 index 000000000000..83a428bfa7e2 --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/MetricsIntegrationTest.java @@ -0,0 +1,151 @@ +package de.tum.in.www1.artemis; + +import static de.tum.in.www1.artemis.service.util.ZonedDateTimeUtil.toRelativeTime; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.time.Instant; +import java.util.Comparator; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.util.ReflectionTestUtils; + +import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.Exercise; +import de.tum.in.www1.artemis.domain.Result; +import de.tum.in.www1.artemis.domain.Submission; +import de.tum.in.www1.artemis.service.scheduled.ParticipantScoreScheduleService; +import de.tum.in.www1.artemis.web.rest.dto.metrics.ExerciseInformationDTO; +import de.tum.in.www1.artemis.web.rest.dto.metrics.StudentMetricsDTO; + +class MetricsIntegrationTest extends AbstractSpringIntegrationIndependentTest { + + private static final String TEST_PREFIX = "metricsintegration"; + + @Autowired + private ParticipantScoreScheduleService participantScoreScheduleService; + + private Course course; + + private static final String STUDENT_OF_COURSE = TEST_PREFIX + "student1"; + + @BeforeEach + void setupTestScenario() { + // Prevents the ParticipantScoreScheduleService from scheduling tasks related to prior results + ReflectionTestUtils.setField(participantScoreScheduleService, "lastScheduledRun", Optional.of(Instant.now())); + ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 100; + participantScoreScheduleService.activate(); + + userUtilService.addUsers(TEST_PREFIX, 3, 1, 1, 1); + + course = courseUtilService.createCourseWithAllExerciseTypesAndParticipationsAndSubmissionsAndResults(TEST_PREFIX, true); + + userUtilService.createAndSaveUser(TEST_PREFIX + "user1337"); + } + + @AfterEach + void cleanup() { + ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 500; + } + + @Nested + class PreAuthorize { + + @Test + @WithMockUser(username = TEST_PREFIX + "user1337", roles = "INSTRUCTOR") + void shouldReturnForbiddenForUserNotInCourse() throws Exception { + request.get("/api/metrics/course/" + course.getId() + "/student", HttpStatus.FORBIDDEN, StudentMetricsDTO.class); + } + } + + @Nested + class ExerciseMetrics { + + @Test + @WithMockUser(username = STUDENT_OF_COURSE, roles = "USER") + void shouldReturnExerciseInformation() throws Exception { + final var result = request.get("/api/metrics/course/" + course.getId() + "/student", HttpStatus.OK, StudentMetricsDTO.class); + assertThat(result).isNotNull(); + assertThat(result.exerciseMetrics()).isNotNull(); + final var exerciseInformation = result.exerciseMetrics().exerciseInformation(); + + final var exercises = exerciseRepository.findAllExercisesByCourseId(course.getId()); + final var expectedDTOs = exercises.stream().map(ExerciseInformationDTO::of).collect(Collectors.toSet()); + + assertThat(exerciseInformation.values()).containsExactlyInAnyOrderElementsOf(expectedDTOs); + assertThat(exerciseInformation).allSatisfy((id, dto) -> assertThat(id).isEqualTo(dto.id())); + } + + @Disabled // TODO: reduce jacoco missing by one after enabled + @Test + @WithMockUser(username = STUDENT_OF_COURSE, roles = "USER") + void shouldReturnAverageScores() throws Exception { + // Wait for the scheduler to execute its task + participantScoreScheduleService.executeScheduledTasks(); + await().until(() -> participantScoreScheduleService.isIdle()); + + final var result = request.get("/api/metrics/course/" + course.getId() + "/student", HttpStatus.OK, StudentMetricsDTO.class); + assertThat(result).isNotNull(); + assertThat(result.exerciseMetrics()).isNotNull(); + final var averageScores = result.exerciseMetrics().averageScore(); + + final var exercises = exerciseRepository.findAllExercisesByCourseId(course.getId()); + + final var expectedMap = exercises.stream().map(Exercise::getId).collect( + Collectors.toMap(Function.identity(), id -> resultRepository.findAllByParticipationExerciseId(id).stream().mapToDouble(Result::getScore).average().orElse(0))); + + assertThat(averageScores).isEqualTo(expectedMap); + } + + @Test + @WithMockUser(username = STUDENT_OF_COURSE, roles = "USER") + void shouldReturnAverageLatestSubmission() throws Exception { + final var result = request.get("/api/metrics/course/" + course.getId() + "/student", HttpStatus.OK, StudentMetricsDTO.class); + assertThat(result).isNotNull(); + assertThat(result.exerciseMetrics()).isNotNull(); + final var averageLatestSubmissions = result.exerciseMetrics().averageLatestSubmission(); + + final var exercises = exerciseRepository.findAllExercisesByCourseId(course.getId()).stream() + .map(exercise -> exerciseRepository.findWithEagerStudentParticipationsStudentAndSubmissionsById(exercise.getId()).orElseThrow()); + + final var expectedMap = exercises.collect(Collectors.toMap(Exercise::getId, exercise -> { + var latestSubmissions = exercise.getStudentParticipations().stream() + .map(participation -> participation.getSubmissions().stream().max(Comparator.comparing(Submission::getSubmissionDate)).orElseThrow()); + return latestSubmissions.mapToDouble(submission -> toRelativeTime(exercise.getReleaseDate(), exercise.getDueDate(), submission.getSubmissionDate())).average() + .orElseThrow(); + })); + + assertThat(averageLatestSubmissions).isEqualTo(expectedMap); + } + + @Test + @WithMockUser(username = STUDENT_OF_COURSE, roles = "USER") + void shouldReturnLatestSubmission() throws Exception { + final var result = request.get("/api/metrics/course/" + course.getId() + "/student", HttpStatus.OK, StudentMetricsDTO.class); + assertThat(result).isNotNull(); + assertThat(result.exerciseMetrics()).isNotNull(); + final var latestSubmissions = result.exerciseMetrics().latestSubmission(); + + final var exercises = exerciseRepository.findAllExercisesByCourseId(course.getId()).stream() + .map(exercise -> exerciseRepository.findWithEagerStudentParticipationsStudentAndSubmissionsById(exercise.getId()).orElseThrow()); + + final var expectedMap = exercises.collect(Collectors.toMap(Exercise::getId, exercise -> { + final var absoluteTime = exercise.getStudentParticipations().stream().flatMap(participation -> participation.getSubmissions().stream()) + .map(Submission::getSubmissionDate).max(Comparator.naturalOrder()).orElseThrow(); + return toRelativeTime(exercise.getReleaseDate(), exercise.getDueDate(), absoluteTime); + })); + + assertThat(latestSubmissions).isEqualTo(expectedMap); + } + } +} diff --git a/src/test/java/de/tum/in/www1/artemis/service/HttpRequestUtilsTest.java b/src/test/java/de/tum/in/www1/artemis/service/util/HttpRequestUtilsTest.java similarity index 97% rename from src/test/java/de/tum/in/www1/artemis/service/HttpRequestUtilsTest.java rename to src/test/java/de/tum/in/www1/artemis/service/util/HttpRequestUtilsTest.java index 465433784515..4d2b31fefd79 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/HttpRequestUtilsTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/util/HttpRequestUtilsTest.java @@ -1,4 +1,4 @@ -package de.tum.in.www1.artemis.service; +package de.tum.in.www1.artemis.service.util; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.anyString; @@ -9,8 +9,6 @@ import org.junit.jupiter.api.Test; -import de.tum.in.www1.artemis.service.util.HttpRequestUtils; - class HttpRequestUtilsTest { @Test diff --git a/src/test/java/de/tum/in/www1/artemis/service/util/ZonedDateTimeUtilTest.java b/src/test/java/de/tum/in/www1/artemis/service/util/ZonedDateTimeUtilTest.java new file mode 100644 index 000000000000..3c2bd6863b37 --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/service/util/ZonedDateTimeUtilTest.java @@ -0,0 +1,34 @@ +package de.tum.in.www1.artemis.service.util; + +import static de.tum.in.www1.artemis.service.util.ZonedDateTimeUtil.toRelativeTime; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.ZonedDateTime; + +import org.junit.jupiter.api.Test; + +class ZonedDateTimeUtilTest { + + @Test + void testToRelativeTimeAtStartTime() { + ZonedDateTime originTime = ZonedDateTime.parse("2020-01-01T00:00:00Z"); + ZonedDateTime unitTime = ZonedDateTime.parse("2020-01-01T01:00:00Z"); + assertThat(toRelativeTime(originTime, unitTime, originTime)).isEqualTo(0); + } + + @Test + void testToRelativeTimeAtEndTime() { + ZonedDateTime originTime = ZonedDateTime.parse("2020-01-01T00:00:00Z"); + ZonedDateTime unitTime = ZonedDateTime.parse("2020-01-01T01:00:00Z"); + assertThat(toRelativeTime(originTime, unitTime, unitTime)).isEqualTo(100); + } + + @Test + void testToRelativeTimeInBetween() { + ZonedDateTime originTime = ZonedDateTime.parse("2020-01-01T00:00:00Z"); + ZonedDateTime unitTime = ZonedDateTime.parse("2020-01-01T10:00:00Z"); + ZonedDateTime targetTime = ZonedDateTime.parse("2020-01-01T02:30:00Z"); + assertThat(toRelativeTime(originTime, unitTime, targetTime)).isEqualTo(25); + } + +}