Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
652e994
Add isRated flag to track if the student trains in the rated mode or …
MoritzSpengler Sep 11, 2025
344b564
fix client tests
MoritzSpengler Sep 11, 2025
6103996
fix client tests
MoritzSpengler Sep 13, 2025
975f8a0
Merge branch 'develop' into feature/quiz-exercises/add-free-training
MoritzSpengler Sep 13, 2025
76c0fc7
Merge branch 'develop' into feature/quiz-exercises/add-free-training
MoritzSpengler Sep 15, 2025
869ef5c
Remove Collection.toList
MoritzSpengler Sep 15, 2025
a1a8465
fix test
MoritzSpengler Sep 15, 2025
2d2e840
Merge branch 'develop' into feature/quiz-exercises/add-free-training
MoritzSpengler Sep 16, 2025
facb594
Introduce pagination
MoritzSpengler Sep 16, 2025
32ab02c
test pagination
MoritzSpengler Sep 17, 2025
914f787
introduce pagination
MoritzSpengler Sep 19, 2025
56592a4
Add Test cases for new paging logic
MoritzSpengler Sep 20, 2025
ae60bd2
Merge branch 'develop' into feature/quiz-exercises/add-free-training
MoritzSpengler Sep 20, 2025
c338a35
Add java doc
MoritzSpengler Sep 20, 2025
6a364e3
Merge remote-tracking branch 'origin/feature/quiz-exercises/add-free-…
MoritzSpengler Sep 20, 2025
e8361c2
annotate questionIDs with Nullable
MoritzSpengler Sep 20, 2025
9a396ef
Upper case for key words
MoritzSpengler Sep 20, 2025
4ab77a4
Add json.non_empty annotation and pass -1 instead of an empty list
MoritzSpengler Sep 20, 2025
4c48b3c
Add sentinel earlier
MoritzSpengler Sep 20, 2025
a84cc47
Fix small bug for multiple courses
MoritzSpengler Sep 20, 2025
f51a3ce
Add Course Id column to the progress table and allow course filtering
MoritzSpengler Sep 20, 2025
74f15fd
Add Course Id column to the progress table and allow course filtering
MoritzSpengler Sep 20, 2025
10ae206
java doc
MoritzSpengler Sep 20, 2025
95c1ac1
add -1 for empty question ids
MoritzSpengler Sep 20, 2025
bf10efe
Change Page to Slice and implement Code suggestions.
MoritzSpengler Sep 22, 2025
d470eb6
Fix Test
MoritzSpengler Sep 22, 2025
e603994
add rollback
MoritzSpengler Sep 22, 2025
527e062
Add foreign key constraint
MoritzSpengler Sep 22, 2025
d48d3f7
Add boolean flag to track session state and refactor dto usage
MoritzSpengler Sep 23, 2025
816e18d
java doc
MoritzSpengler Sep 23, 2025
d8fea5f
fix tests
MoritzSpengler Sep 23, 2025
0eef4c4
Set size for pagination to 20 after successful tests
MoritzSpengler Sep 25, 2025
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 @@ -16,6 +16,9 @@ public class QuizQuestionProgress extends DomainObject {
@Column(name = "user_id")
private long userId;

@Column(name = "course_id")
private long courseId;

@Column(name = "quiz_question_id")
private long quizQuestionId;

Expand All @@ -34,6 +37,14 @@ public void setUserId(long userId) {
this.userId = userId;
}

public long getCourseId() {
return courseId;
}

public void setCourseId(long courseId) {
this.courseId = courseId;
}

public long getQuizQuestionId() {
return quizQuestionId;
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package de.tum.cit.aet.artemis.quiz.dto.question;

import java.util.Set;

import jakarta.annotation.Nullable;
import jakarta.validation.constraints.NotNull;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record QuizQuestionTrainingDTO(@NotNull QuizQuestionWithSolutionDTO quizQuestionWithSolutionDTO, boolean isRated, @Nullable Set<Long> questionIds, boolean isNewSession) {

public long getId() {
return quizQuestionWithSolutionDTO().quizQuestionBaseDTO().id();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ public interface QuizQuestionProgressRepository extends ArtemisJpaRepository<Qui

Optional<QuizQuestionProgress> findByUserIdAndQuizQuestionId(long userId, long quizQuestionId);

Set<QuizQuestionProgress> findAllByUserIdAndQuizQuestionIdIn(long userId, Set<Long> quizQuestionIds);
Set<QuizQuestionProgress> findAllByUserIdAndCourseId(long userId, long courseId);

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
Expand Down Expand Up @@ -43,14 +45,22 @@ public interface QuizQuestionRepository extends ArtemisJpaRepository<QuizQuestio
* Finds all quiz question from a course that are open for practice.
*
* @param courseId of the course
* @param pageable pagination information
* @return a set of quiz questions
*/
@Query("""
SELECT q
FROM QuizQuestion q
WHERE q.exercise.course.id = :courseId AND q.exercise.isOpenForPractice = TRUE
""")
Set<QuizQuestion> findAllQuizQuestionsByCourseId(@Param("courseId") Long courseId);
Slice<QuizQuestion> findAllPracticeQuizQuestionsByCourseId(@Param("courseId") Long courseId, Pageable pageable);

@Query("""
SELECT q
FROM QuizQuestion q
WHERE q.exercise.course.id = :courseId AND q.exercise.isOpenForPractice = TRUE AND q.id NOT IN (:ids)
""")
Slice<QuizQuestion> findAllDueQuestions(@Param("ids") Set<Long> ids, @Param("courseId") long courseId, Pageable pageable);

@Query("""
SELECT COUNT(q) > 0
Expand All @@ -59,6 +69,13 @@ SELECT COUNT(q) > 0
""")
boolean areQuizQuestionsAvailableForPractice(@Param("courseId") Long courseId);

@Query("""
SELECT COUNT(q)
FROM QuizQuestion q
WHERE q.exercise.course.id = :courseId AND q.exercise.isOpenForPractice = TRUE
""")
long countAllPracticeQuizQuestionsByCourseId(@Param("courseId") long courseId);

default DragAndDropQuestion findDnDQuestionByIdOrElseThrow(Long questionId) {
return getValueElseThrow(findDnDQuestionById(questionId), questionId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,23 @@
import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE;

import java.time.ZonedDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;

import de.tum.cit.aet.artemis.quiz.domain.QuizQuestion;
import de.tum.cit.aet.artemis.quiz.domain.QuizQuestionProgress;
import de.tum.cit.aet.artemis.quiz.domain.QuizQuestionProgressData;
import de.tum.cit.aet.artemis.quiz.domain.SubmittedAnswer;
import de.tum.cit.aet.artemis.quiz.dto.question.QuizQuestionTrainingDTO;
import de.tum.cit.aet.artemis.quiz.dto.question.QuizQuestionWithSolutionDTO;
import de.tum.cit.aet.artemis.quiz.repository.QuizQuestionProgressRepository;
import de.tum.cit.aet.artemis.quiz.repository.QuizQuestionRepository;

Expand Down Expand Up @@ -85,30 +87,57 @@ public void updateProgressCalculations(QuizQuestionProgressData data, double sco
}

/**
* Get the sorted List of 10 quiz questions based on their due date
* Get the sorted List of quiz questions based on their due date
*
* @param courseId ID of the course for which the quiz questions are to be fetched
* @param userId ID of the user for whom the quiz questions are to be fetched
* @return A list of 10 quiz questions sorted by due date
* @param courseId ID of the course for which the quiz questions are to be fetched
* @param userId ID of the user for whom the quiz questions are to be fetched
* @param pageable Pageable object with pagination information
* @param questionIds Optional set of question IDs to filter the questions
* @param isNewSession Boolean indicating if it is a new training session or a continuation of an existing one
* @return A list of quiz questions sorted by due date
*/
public List<QuizQuestion> getQuestionsForSession(Long courseId, Long userId) {
Set<QuizQuestion> allQuestions = quizQuestionRepository.findAllQuizQuestionsByCourseId(courseId);
Set<Long> questionIds = allQuestions.stream().map(QuizQuestion::getId).collect(Collectors.toSet());
Set<QuizQuestionProgress> progressList = quizQuestionProgressRepository.findAllByUserIdAndQuizQuestionIdIn(userId, questionIds);
public Slice<QuizQuestionTrainingDTO> getQuestionsForSession(long courseId, long userId, Pageable pageable, Set<Long> questionIds, boolean isNewSession) {
ZonedDateTime now = ZonedDateTime.now();
if (isNewSession) {
Set<QuizQuestionProgress> allProgress = quizQuestionProgressRepository.findAllByUserIdAndCourseId(userId, courseId);

Map<Long, ZonedDateTime> dueDateMap = progressList.stream().collect(Collectors.toMap(QuizQuestionProgress::getQuizQuestionId, progress -> {
QuizQuestionProgressData data = progress.getProgressJson();
return (data != null && data.getDueDate() != null) ? data.getDueDate() : ZonedDateTime.now();
}));
questionIds = allProgress.stream().filter(progress -> {
QuizQuestionProgressData data = progress.getProgressJson();
return data != null && data.getDueDate() != null && data.getDueDate().isAfter(now);
}).map(QuizQuestionProgress::getQuizQuestionId).collect(Collectors.toSet());
isNewSession = false;
}

ZonedDateTime now = ZonedDateTime.now();
if (areQuestionsDue(courseId, questionIds.size())) {
return loadDueQuestions(questionIds, courseId, pageable, isNewSession);
}
else {
return loadAllPracticeQuestions(courseId, pageable, isNewSession);
}
}

private boolean areQuestionsDue(Long courseId, int notDueCount) {
long totalQuestionsCount = quizQuestionRepository.countAllPracticeQuizQuestionsByCourseId(courseId);

return notDueCount < totalQuestionsCount;
}

private Slice<QuizQuestionTrainingDTO> loadDueQuestions(Set<Long> questionIds, Long courseId, Pageable pageable, boolean isNewSession) {
Slice<QuizQuestion> questionPage = quizQuestionRepository.findAllDueQuestions(questionIds, courseId, pageable);

return questionPage.map(question -> {
QuizQuestionWithSolutionDTO dto = QuizQuestionWithSolutionDTO.of(question);
return new QuizQuestionTrainingDTO(dto, true, questionIds, isNewSession);
});
}

List<QuizQuestion> dueQuestions = allQuestions.stream().filter(q -> {
ZonedDateTime dueDate = dueDateMap.getOrDefault(q.getId(), now);
return !dueDate.toLocalDate().isAfter(now.toLocalDate());
}).sorted(Comparator.comparing(q -> dueDateMap.getOrDefault(q.getId(), now))).limit(10).toList();
private Slice<QuizQuestionTrainingDTO> loadAllPracticeQuestions(Long courseId, Pageable pageable, boolean isNewSession) {
Slice<QuizQuestion> questionPage = quizQuestionRepository.findAllPracticeQuizQuestionsByCourseId(courseId, pageable);

return dueQuestions;
return questionPage.map(question -> {
QuizQuestionWithSolutionDTO dto = QuizQuestionWithSolutionDTO.of(question);
return new QuizQuestionTrainingDTO(dto, false, null, isNewSession);
});
}

/**
Expand Down Expand Up @@ -233,17 +262,19 @@ public boolean questionsAvailableForTraining(Long courseId) {
*
* @param question The quiz question for which the progress is to be saved
* @param userId The id of the user
* @param courseId The id of the course
* @param answer The submitted answer for the question
* @param answeredAt The time when the question was answered
*/
public void saveProgressFromTraining(QuizQuestion question, Long userId, SubmittedAnswer answer, ZonedDateTime answeredAt) {
public void saveProgressFromTraining(QuizQuestion question, long userId, long courseId, SubmittedAnswer answer, ZonedDateTime answeredAt) {
QuizQuestionProgress existingProgress = quizQuestionProgressRepository.findByUserIdAndQuizQuestionId(userId, question.getId()).orElse(new QuizQuestionProgress());
QuizQuestionProgressData data = existingProgress.getProgressJson() != null ? existingProgress.getProgressJson() : new QuizQuestionProgressData();

ZonedDateTime dueDate = data.getDueDate();
if (dueDate == null || !dueDate.isAfter(answeredAt)) {
existingProgress.setQuizQuestionId(question.getId());
existingProgress.setUserId(userId);
existingProgress.setCourseId(courseId);
double score = question.getPoints() > 0 ? answer.getScoreInPoints() / question.getPoints() : 0.0;
updateProgressWithNewAttempt(data, score, answeredAt);
updateProgressCalculations(data, score, existingProgress, answeredAt);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import de.tum.cit.aet.artemis.quiz.domain.QuizQuestion;
import de.tum.cit.aet.artemis.quiz.domain.SubmittedAnswer;
import de.tum.cit.aet.artemis.quiz.dto.QuizTrainingAnswerDTO;
import de.tum.cit.aet.artemis.quiz.dto.submittedanswer.SubmittedAnswerAfterEvaluationDTO;
import de.tum.cit.aet.artemis.quiz.repository.QuizQuestionRepository;

Expand All @@ -31,23 +30,27 @@ public QuizTrainingService(QuizQuestionProgressService quizQuestionProgressServi
/**
* Submits a quiz question for training mode, calculates scores and creates a result.
*
* @param quizQuestionId the id of the quiz question being submitted
* @param userId the id of the user who is submitting the quiz
* @param studentSubmittedAnswer the answer submitted by the user
* @param answeredAt the time when the question was answered
* @param quizQuestionId the id of the quiz question being submitted
* @param userId the id of the user who is submitting the quiz
* @param courseId the id of the course
* @param submittedAnswer the answer submitted by the user
* @param isRated whether the answer is rated (i.e. updates progress)
* @param answeredAt the time when the question was answered
* @return a DTO containing the submitted answer after the evaluation
*/
public SubmittedAnswerAfterEvaluationDTO submitForTraining(long quizQuestionId, long userId, QuizTrainingAnswerDTO studentSubmittedAnswer, ZonedDateTime answeredAt) {
public SubmittedAnswerAfterEvaluationDTO submitForTraining(long quizQuestionId, long userId, long courseId, SubmittedAnswer submittedAnswer, boolean isRated,
ZonedDateTime answeredAt) {
QuizQuestion quizQuestion = quizQuestionRepository.findByIdElseThrow(quizQuestionId);
SubmittedAnswer answer = studentSubmittedAnswer.submittedAnswer();

double score = quizQuestion.scoreForAnswer(answer);
double score = quizQuestion.scoreForAnswer(submittedAnswer);

answer.setScoreInPoints(score);
answer.setQuizQuestion(quizQuestion);
submittedAnswer.setScoreInPoints(score);
submittedAnswer.setQuizQuestion(quizQuestion);

quizQuestionProgressService.saveProgressFromTraining(quizQuestion, userId, answer, answeredAt);
if (isRated) {
quizQuestionProgressService.saveProgressFromTraining(quizQuestion, userId, courseId, submittedAnswer, answeredAt);
}

return SubmittedAnswerAfterEvaluationDTO.of(answer);
return SubmittedAnswerAfterEvaluationDTO.of(submittedAnswer);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,24 @@

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

import jakarta.validation.Valid;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import de.tum.cit.aet.artemis.core.domain.Course;
Expand All @@ -25,10 +30,10 @@
import de.tum.cit.aet.artemis.core.repository.UserRepository;
import de.tum.cit.aet.artemis.core.security.Role;
import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent;
import de.tum.cit.aet.artemis.core.security.annotations.enforceRoleInCourse.EnforceAtLeastStudentInCourse;
import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService;
import de.tum.cit.aet.artemis.quiz.domain.QuizQuestion;
import de.tum.cit.aet.artemis.quiz.dto.QuizTrainingAnswerDTO;
import de.tum.cit.aet.artemis.quiz.dto.question.QuizQuestionWithSolutionDTO;
import de.tum.cit.aet.artemis.quiz.domain.SubmittedAnswer;
import de.tum.cit.aet.artemis.quiz.dto.question.QuizQuestionTrainingDTO;
import de.tum.cit.aet.artemis.quiz.dto.submittedanswer.SubmittedAnswerAfterEvaluationDTO;
import de.tum.cit.aet.artemis.quiz.service.QuizQuestionProgressService;
import de.tum.cit.aet.artemis.quiz.service.QuizTrainingService;
Expand Down Expand Up @@ -61,44 +66,48 @@ public QuizTrainingResource(UserRepository userRepository, CourseRepository cour
}

/**
* Retrieves 10 quiz questions for the training session for the given course. The questions are selected based on the spaced repetition algorithm.
* Retrieves the quiz questions for the training session for the given course. The questions are selected based on the spaced repetition algorithm.
*
* @param courseId the id of the course whose quiz questions should be retrieved
* @return a list of 10 quiz questions for the training session
* @param courseId the id of the course whose quiz questions should be retrieved
* @param pageable pagination information
* @param isNewSession whether a new training session is being started
* @param questionIds optional set of question IDs to filter the questions
* @return a list of quiz questions for the training session depending on the pagination information
*/
@GetMapping("courses/{courseId}/training-questions")
@EnforceAtLeastStudent
public ResponseEntity<List<QuizQuestionWithSolutionDTO>> getQuizQuestionsForPractice(@PathVariable long courseId) {
@PostMapping("courses/{courseId}/training-questions")
@EnforceAtLeastStudentInCourse
public ResponseEntity<List<QuizQuestionTrainingDTO>> getQuizQuestionsForPractice(@PathVariable long courseId, Pageable pageable, @RequestParam boolean isNewSession,
@RequestBody Set<Long> questionIds) {
log.info("REST request to get quiz questions for course with id : {}", courseId);
User user = userRepository.getUserWithGroupsAndAuthorities();
Course course = courseRepository.findByIdElseThrow(courseId);
authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, user);

List<QuizQuestion> quizQuestions = quizQuestionProgressService.getQuestionsForSession(courseId, user.getId());
List<QuizQuestionWithSolutionDTO> quizQuestionsWithSolutions = quizQuestions.stream().map(QuizQuestionWithSolutionDTO::of).toList();
return ResponseEntity.ok(quizQuestionsWithSolutions);
Slice<QuizQuestionTrainingDTO> quizQuestionsSlice = quizQuestionProgressService.getQuestionsForSession(courseId, user.getId(), pageable, questionIds, isNewSession);
HttpHeaders headers = new HttpHeaders();
headers.add("X-Has-Next", Boolean.toString(quizQuestionsSlice.hasNext()));
return new ResponseEntity<>(quizQuestionsSlice.getContent(), headers, HttpStatus.OK);
}

/**
* POST /courses/:courseId/training/:quizQuestionId/submit: Submit a new quizQuestion for training mode.
*
* @param courseId the id of the course containing the quiz question
* @param quizQuestionId the id of the quiz question which is being answered
* @param isRated whether the submitted answer should be rated (i.e. affect the user's progress= or not
* @param submittedAnswer the submitted answer by the user for the quiz question
* @return the ResponseEntity with status 200 (OK) and the result of the evaluated submitted answer as its body
*/
@PostMapping("courses/{courseId}/training-questions/{quizQuestionId}/submit")
@EnforceAtLeastStudent
public ResponseEntity<SubmittedAnswerAfterEvaluationDTO> submitForTraining(@PathVariable long courseId, @PathVariable long quizQuestionId,
@Valid @RequestBody QuizTrainingAnswerDTO submittedAnswer) {
public ResponseEntity<SubmittedAnswerAfterEvaluationDTO> submitForTraining(@PathVariable long courseId, @PathVariable long quizQuestionId, @RequestParam boolean isRated,
@Valid @RequestBody SubmittedAnswer submittedAnswer) {
log.debug("REST request to submit QuizQuestion for training : {}", submittedAnswer);

User user = userRepository.getUserWithGroupsAndAuthorities();
Course course = courseRepository.findByIdElseThrow(courseId);
authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, user);
ZonedDateTime answeredAt = ZonedDateTime.now();

SubmittedAnswerAfterEvaluationDTO result = quizTrainingService.submitForTraining(quizQuestionId, user.getId(), submittedAnswer, answeredAt);
SubmittedAnswerAfterEvaluationDTO result = quizTrainingService.submitForTraining(quizQuestionId, user.getId(), courseId, submittedAnswer, isRated, answeredAt);

return ResponseEntity.ok(result);
}
Expand Down
Loading
Loading