Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 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
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package de.tum.cit.aet.artemis.quiz.dto;

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

import com.fasterxml.jackson.annotation.JsonInclude;

import de.tum.cit.aet.artemis.quiz.domain.SubmittedAnswer;

@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record QuizTrainingAnswerDTO(@Nullable SubmittedAnswer submittedAnswer) {
public record QuizTrainingAnswerDTO(@NotNull SubmittedAnswer submittedAnswer, boolean isRated) {
}
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) {

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,58 @@ 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
* @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) {
ZonedDateTime now = ZonedDateTime.now();
if (questionIds == null) {
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());
if (questionIds.isEmpty()) {
questionIds.add(-1L);
}
}

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

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) {
Slice<QuizQuestion> questionPage = quizQuestionRepository.findAllDueQuestions(questionIds, courseId, pageable);

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

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) {
Slice<QuizQuestion> questionPage = quizQuestionRepository.findAllPracticeQuizQuestionsByCourseId(courseId, pageable);

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

/**
Expand Down Expand Up @@ -233,17 +263,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 @@ -33,20 +33,25 @@ public QuizTrainingService(QuizQuestionProgressService quizQuestionProgressServi
*
* @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 studentSubmittedAnswer the answer submitted by the user
* @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, QuizTrainingAnswerDTO studentSubmittedAnswer,
ZonedDateTime answeredAt) {
QuizQuestion quizQuestion = quizQuestionRepository.findByIdElseThrow(quizQuestionId);
SubmittedAnswer answer = studentSubmittedAnswer.submittedAnswer();
boolean isRated = studentSubmittedAnswer.isRated();

double score = quizQuestion.scoreForAnswer(answer);

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

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

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

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;
Expand All @@ -25,10 +29,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.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,22 +65,28 @@ 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
* @param courseId the id of the course whose quiz questions should be retrieved
* @param pageable pagination information
* @param questionIds optional set of question IDs to filter the questions
* @return a list of 10 quiz questions for the training session
*/
@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,
@RequestBody(required = false) 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);
if (questionIds != null && questionIds.isEmpty()) {
questionIds.add(-1L);
}

Slice<QuizQuestionTrainingDTO> quizQuestionsSlice = quizQuestionProgressService.getQuestionsForSession(courseId, user.getId(), pageable, questionIds);
HttpHeaders headers = new HttpHeaders();
headers.add("X-Has-Next", Boolean.toString(quizQuestionsSlice.hasNext()));
return new ResponseEntity<>(quizQuestionsSlice.getContent(), headers, HttpStatus.OK);
}

/**
Expand All @@ -98,7 +108,7 @@ public ResponseEntity<SubmittedAnswerAfterEvaluationDTO> submitForTraining(@Path
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, answeredAt);

return ResponseEntity.ok(result);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="20250920163252-add-courseid-quiz-question-progress" author="MoritzSpengler">
<addColumn tableName="quiz_question_progress">
<column name="course_id" type="bigint"/>
</addColumn>
</changeSet>

<changeSet id="20250920163252-populate-courseid-quiz-question-progress" author="MoritzSpengler">
<comment>Populate course_id for existing entries</comment>
<sql>
UPDATE quiz_question_progress qqp
SET course_id = (
SELECT e.course_id
FROM quiz_question qq
JOIN exercise e ON qq.exercise_id = e.id
WHERE qq.id = qqp.quiz_question_id
)
WHERE qqp.course_id IS NULL;
</sql>
</changeSet>

<changeSet id="20250920163252-set-not-null-courseid-quiz-question-progress-and-foreign-key" author="MoritzSpengler">
<addNotNullConstraint tableName="quiz_question_progress"
columnName="course_id"
columnDataType="bigint"/>

<addForeignKeyConstraint
baseTableName="quiz_question_progress"
baseColumnNames="course_id"
referencedTableName="course"
referencedColumnNames="id"
constraintName="fk_quiz_question_progress_course"
onDelete="CASCADE"/>
</changeSet>
</databaseChangeLog>
1 change: 1 addition & 0 deletions src/main/resources/config/liquibase/master.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
<include file="classpath:config/liquibase/changelog/20250731140151_changelog.xml" relativeToChangelogFile="false"/>
<include file="classpath:config/liquibase/changelog/20250802175051_changelog.xml" relativeToChangelogFile="false"/>
<include file="classpath:config/liquibase/changelog/20250911170100_changelog.xml" relativeToChangelogFile="false"/>
<include file="classpath:config/liquibase/changelog/20250920163252_changelog.xml" relativeToChangelogFile="false"/>
<!-- NOTE: please use the format "YYYYMMDDhhmmss_changelog.xml", i.e. year month day hour minutes seconds and not something else! -->
<!-- we should also stay in a chronological order! -->
<!-- you can use the command 'date '+%Y%m%d%H%M%S' to get the current date and time in the correct format -->
Expand Down
Loading
Loading