Skip to content

Commit

Permalink
Adaptive learning: Add endpoint to retrieve student course metrics (#…
Browse files Browse the repository at this point in the history
…8508)

Co-authored-by: Johannes Stöhr <[email protected]>
  • Loading branch information
MaximilianAnzinger and JohannesStoehr authored May 14, 2024
1 parent 2f0dee7 commit c5a5c28
Show file tree
Hide file tree
Showing 18 changed files with 594 additions and 7 deletions.
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public interface ExerciseRepository extends JpaRepository<Exercise, Long> {
FROM Exercise e
WHERE e.course.id = :courseId
""")
Set<Exercise> findAllExercisesByCourseId(@Param("courseId") Long courseId);
Set<Exercise> findAllExercisesByCourseId(@Param("courseId") long courseId);

@Query("""
SELECT e
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ public interface ParticipantScoreRepository extends JpaRepository<ParticipantSco
@EntityGraph(type = LOAD, attributePaths = { "exercise", "lastResult", "lastRatedResult" })
List<ParticipantScore> findAllByExercise(Exercise exercise);

@Query("""
SELECT p
FROM ParticipantScore p
WHERE p.exercise.course.id = :courseId
""")
List<ParticipantScore> findAllByCourseId(@Param("courseId") long courseId);

@Query("""
SELECT AVG(p.lastScore)
FROM ParticipantScore p
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Long> 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<Long> userId = findIdByLogin(currentUserLogin);
return userId.orElseThrow(() -> new EntityNotFoundException("User: " + currentUserLogin));
}

/**
* Retrieve a user by its login, or else throw exception
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Exercise, Long> {

/**
* 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<ExerciseInformationDTO> 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<ScoreDTO> findAverageScore(@Param("exerciseIds") Set<Long> 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<ScoreDTO> findScore(@Param("exerciseIds") Set<Long> 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<ResourceTimestampDTO> findLatestSubmissionDatesForUser(@Param("exerciseIds") Set<Long> 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<ResourceTimestampDTO> findLatestSubmissionDates(@Param("exerciseIds") Set<Long> exerciseIds);
}
Original file line number Diff line number Diff line change
@@ -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<ExerciseInformationDTO> 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<ResourceTimestampDTO> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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());
}
}
49 changes: 49 additions & 0 deletions src/main/java/de/tum/in/www1/artemis/web/rest/MetricsResource.java
Original file line number Diff line number Diff line change
@@ -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<StudentMetricsDTO> 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);
}
}
Loading

0 comments on commit c5a5c28

Please sign in to comment.