Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adaptive learning: Add endpoint to retrieve student course metrics #8508

Merged
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
50b08a4
implement first endpoint draft
MaximilianAnzinger Apr 30, 2024
92c78eb
add average lateness
MaximilianAnzinger Apr 30, 2024
cc7678e
Merge branch 'develop' into feature/adaptive-learning/add-metrics-ser…
MaximilianAnzinger Apr 30, 2024
3125972
remove unused
MaximilianAnzinger Apr 30, 2024
bde4cde
spotless
MaximilianAnzinger Apr 30, 2024
4c650bc
add long title to general exercise information
MaximilianAnzinger Apr 30, 2024
582aaf6
remove additional exercise metrics and lecture unit information
MaximilianAnzinger May 2, 2024
4800ee6
fix some queries and add test for ExerciseInfo
MaximilianAnzinger May 3, 2024
ce4e362
add templates for remaining tests
MaximilianAnzinger May 3, 2024
4a9b380
Merge branch 'develop' into feature/adaptive-learning/add-metrics-ser…
MaximilianAnzinger May 3, 2024
25b27e6
Add tests
JohannesStoehr May 3, 2024
4b73e45
Add ZonedDateTimeUtilTest
JohannesStoehr May 3, 2024
94f4e98
improve findAverageScore query
MaximilianAnzinger May 8, 2024
de60b0d
Apply suggestions from code review
MaximilianAnzinger May 8, 2024
e0a6637
Add comment for ExerciseInformationDTO equals
MaximilianAnzinger May 8, 2024
324910b
Merge branch 'feature/adaptive-learning/add-metrics-service' of https…
MaximilianAnzinger May 8, 2024
026a085
fix code review suggestions
MaximilianAnzinger May 8, 2024
22cdef2
Merge branch 'develop' into feature/adaptive-learning/add-metrics-ser…
MaximilianAnzinger May 8, 2024
4ffa9a7
fix wrong calculation of relative time
MaximilianAnzinger May 8, 2024
41396d6
improve MetricsIntegrationTest setup
MaximilianAnzinger May 8, 2024
f990643
undo LoggingAspect changes
MaximilianAnzinger May 8, 2024
b02abf7
set default value for non existing last scores to 0
MaximilianAnzinger May 8, 2024
d7a89aa
integrate feedback
MaximilianAnzinger May 9, 2024
2c07879
Merge branch 'develop' into feature/adaptive-learning/add-metrics-ser…
MaximilianAnzinger May 9, 2024
aad358a
Update ParticipantScoreScheduleService.java
MaximilianAnzinger May 10, 2024
928a6ee
Update MetricsIntegrationTest.java
MaximilianAnzinger May 10, 2024
9334a03
Merge branch 'develop' into feature/adaptive-learning/add-metrics-ser…
MaximilianAnzinger May 13, 2024
c42e765
fix Arch test
MaximilianAnzinger May 13, 2024
1b15bb5
add student score to dto
MaximilianAnzinger May 13, 2024
d8c89de
add max points to ExerciseInformationDTO
MaximilianAnzinger May 13, 2024
270a19d
add missing comment
MaximilianAnzinger May 13, 2024
c552592
Update ExerciseMetricsRepository.java
MaximilianAnzinger May 13, 2024
c1897ce
fix code style
MaximilianAnzinger May 13, 2024
ceb9999
add doc comment
MaximilianAnzinger May 13, 2024
4cac313
disable averageScoreTest
MaximilianAnzinger May 13, 2024
fc49f43
remove unused dto
MaximilianAnzinger May 14, 2024
ec7c6ae
fix coverage check
MaximilianAnzinger May 14, 2024
c67e6f9
Merge branch 'develop' into feature/adaptive-learning/add-metrics-ser…
MaximilianAnzinger May 14, 2024
53b8bc0
Update MetricsService.java
MaximilianAnzinger May 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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 getUserId() {
MaximilianAnzinger marked this conversation as resolved.
Show resolved Hide resolved
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,124 @@
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;
import de.tum.in.www1.artemis.web.rest.dto.metrics.SubmissionTimestampDTO;

/**
* 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.class)
FROM Exercise e
WHERE e.course.id = :courseId
""")
Set<ExerciseInformationDTO> findAllExerciseInformationByCourseId(long courseId);

@Query("""
SELECT new de.tum.in.www1.artemis.web.rest.dto.metrics.ScoreDTO(e.id, COALESCE(AVG(r.score), 0))
MaximilianAnzinger marked this conversation as resolved.
Show resolved Hide resolved
FROM Exercise e
LEFT JOIN StudentParticipation p ON e.id = p.exercise.id
LEFT JOIN p.submissions s
LEFT JOIN s.results r
WHERE e.id IN :exerciseIds
AND s.submitted = TRUE
GROUP BY e.id
""")
Set<ScoreDTO> findAverageScore(@Param("exerciseIds") Set<Long> exerciseIds);
MaximilianAnzinger marked this conversation as resolved.
Show resolved Hide resolved

/**
* Get the latest submissions for a user in a set of exercises.
MaximilianAnzinger marked this conversation as resolved.
Show resolved Hide resolved
*
* @param exerciseIds the ids of the exercises
* @param userId the id of the user
* @return the latest submissions for the user in the exercises
MaximilianAnzinger marked this conversation as resolved.
Show resolved Hide resolved
*/
@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> findLatestSubmissionsForUser(@Param("exerciseIds") Set<Long> exerciseIds, @Param("userId") long userId);
MaximilianAnzinger marked this conversation as resolved.
Show resolved Hide resolved

/**
* Get the latest submissions for a set of users in a set of exercises.
MaximilianAnzinger marked this conversation as resolved.
Show resolved Hide resolved
*
* @param exerciseIds the ids of the exercises
* @return the latest submissions for the user in the exercises
MaximilianAnzinger marked this conversation as resolved.
Show resolved Hide resolved
*/
@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
MaximilianAnzinger marked this conversation as resolved.
Show resolved Hide resolved
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> findLatestSubmissions(@Param("exerciseIds") Set<Long> exerciseIds);
MaximilianAnzinger marked this conversation as resolved.
Show resolved Hide resolved

/**
* Get the submission timestamps for a user in a set of exercises.
*
* @param exerciseIds the ids of the exercises
* @param userId the id of the user
* @return the submission timestamps for the user in the exercises
*/
@Query("""
SELECT new de.tum.in.www1.artemis.web.rest.dto.metrics.SubmissionTimestampDTO(s.id, s.submissionDate, r.score)
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
LEFT JOIN s.results r
WHERE s.submitted = TRUE
AND e.id IN :exerciseIds
AND (p.student.id = :userId OR u.id = :userId)
AND r.score = (
SELECT MAX(r2.score)
FROM Result r2
WHERE r2.submission.id = s.id
)
""")
Set<SubmissionTimestampDTO> findSubmissionTimestampsForUser(@Param("exerciseIds") Set<Long> exerciseIds, @Param("userId") long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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.mapping;
import static java.util.stream.Collectors.toMap;
import static java.util.stream.Collectors.toSet;

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;
import de.tum.in.www1.artemis.web.rest.dto.metrics.SubmissionTimestampDTO;

/**
* 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().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 latestSubmissionOfUser = exerciseMetricsRepository.findLatestSubmissionsForUser(exerciseIds, userId);
final var latestSubmissionMap = latestSubmissionOfUser.stream().collect(toMap(ResourceTimestampDTO::id, ResourceTimestampDTO::timestamp));

final var latestSubmissions = exerciseMetricsRepository.findLatestSubmissions(exerciseIds);
final ToDoubleFunction<ResourceTimestampDTO> relativeTime = dto -> toRelativeTime(exerciseInfoMap.get(dto.id()).start(), exerciseInfoMap.get(dto.id()).due(),
MaximilianAnzinger marked this conversation as resolved.
Show resolved Hide resolved
dto.timestamp());
final var averageLatestSubmissionMap = latestSubmissions.stream().collect(groupingBy(ResourceTimestampDTO::id, averagingDouble(relativeTime)));

final var submissionTimestamps = exerciseMetricsRepository.findSubmissionTimestampsForUser(exerciseIds, userId);
final var submissionTimestampMap = submissionTimestamps.stream().collect(groupingBy(SubmissionTimestampDTO::exerciseId, mapping(identity(), toSet())));

return new ExerciseStudentMetricsDTO(exerciseInfoMap, averageScoreMap, averageLatestSubmissionMap, latestSubmissionMap, submissionTimestampMap);
}
}
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%
FelixTJDietrich marked this conversation as resolved.
Show resolved Hide resolved
*
* @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) {
MaximilianAnzinger marked this conversation as resolved.
Show resolved Hide resolved
return 100.0 * target.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.getUserId(); // 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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
MaximilianAnzinger marked this conversation as resolved.
Show resolved Hide resolved
* @param due the due date 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, Class<? extends Exercise> type) {

public static <E extends Exercise> 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.getClass());
}

@Override
public boolean equals(Object other) {
if (other == null) {
return false;
}

if (other instanceof ExerciseInformationDTO otherDTO) {
return id == otherDTO.id && shortName.equals(otherDTO.shortName) && title.equals(otherDTO.title) && start.isEqual(otherDTO.start) && due.isEqual(otherDTO.due)
&& type.equals(otherDTO.type);
}

return false;
}
MaximilianAnzinger marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package de.tum.in.www1.artemis.web.rest.dto.metrics;

import java.time.ZonedDateTime;
import java.util.Map;
import java.util.Set;

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 averageLatestSubmission the average time of the latest submissions in the exercises
* @param latestSubmission the latest submission of the students in the exercises
* @param submissionTimestamps the submission timestamps of the students in the exercises
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record ExerciseStudentMetricsDTO(Map<Long, ExerciseInformationDTO> exerciseInformation, Map<Long, Double> averageScore, Map<Long, Double> averageLatestSubmission,
Map<Long, ZonedDateTime> latestSubmission, Map<Long, Set<SubmissionTimestampDTO>> submissionTimestamps) {
}
Loading
Loading