diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/domain/QuizQuestionProgress.java b/src/main/java/de/tum/cit/aet/artemis/quiz/domain/QuizQuestionProgress.java index af2d02097005..8bdfcb7a66ed 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/domain/QuizQuestionProgress.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/domain/QuizQuestionProgress.java @@ -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; @@ -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; } diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/dto/QuizTrainingAnswerDTO.java b/src/main/java/de/tum/cit/aet/artemis/quiz/dto/QuizTrainingAnswerDTO.java deleted file mode 100644 index aab0be4864ed..000000000000 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/dto/QuizTrainingAnswerDTO.java +++ /dev/null @@ -1,11 +0,0 @@ -package de.tum.cit.aet.artemis.quiz.dto; - -import jakarta.annotation.Nullable; - -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) { -} diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/dto/question/QuizQuestionTrainingDTO.java b/src/main/java/de/tum/cit/aet/artemis/quiz/dto/question/QuizQuestionTrainingDTO.java new file mode 100644 index 000000000000..3e3951f021b2 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/dto/question/QuizQuestionTrainingDTO.java @@ -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 questionIds, boolean isNewSession) { + + public long getId() { + return quizQuestionWithSolutionDTO().quizQuestionBaseDTO().id(); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizQuestionProgressRepository.java b/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizQuestionProgressRepository.java index 75bae40824da..1a8091c174c4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizQuestionProgressRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizQuestionProgressRepository.java @@ -19,6 +19,6 @@ public interface QuizQuestionProgressRepository extends ArtemisJpaRepository findByUserIdAndQuizQuestionId(long userId, long quizQuestionId); - Set findAllByUserIdAndQuizQuestionIdIn(long userId, Set quizQuestionIds); + Set findAllByUserIdAndCourseId(long userId, long courseId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizQuestionRepository.java b/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizQuestionRepository.java index 38441b6022b8..ffffd49c33c0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizQuestionRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizQuestionRepository.java @@ -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; @@ -43,6 +45,7 @@ public interface QuizQuestionRepository extends ArtemisJpaRepository findAllQuizQuestionsByCourseId(@Param("courseId") Long courseId); + Slice 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 findAllDueQuestions(@Param("ids") Set ids, @Param("courseId") long courseId, Pageable pageable); @Query(""" SELECT COUNT(q) > 0 @@ -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); } diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizQuestionProgressService.java b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizQuestionProgressService.java index 450b56f5ac4d..a3d988370c1c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizQuestionProgressService.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizQuestionProgressService.java @@ -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; @@ -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 getQuestionsForSession(Long courseId, Long userId) { - Set allQuestions = quizQuestionRepository.findAllQuizQuestionsByCourseId(courseId); - Set questionIds = allQuestions.stream().map(QuizQuestion::getId).collect(Collectors.toSet()); - Set progressList = quizQuestionProgressRepository.findAllByUserIdAndQuizQuestionIdIn(userId, questionIds); + public Slice getQuestionsForSession(long courseId, long userId, Pageable pageable, Set questionIds, boolean isNewSession) { + ZonedDateTime now = ZonedDateTime.now(); + if (isNewSession) { + Set allProgress = quizQuestionProgressRepository.findAllByUserIdAndCourseId(userId, courseId); - Map 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 loadDueQuestions(Set questionIds, Long courseId, Pageable pageable, boolean isNewSession) { + Slice questionPage = quizQuestionRepository.findAllDueQuestions(questionIds, courseId, pageable); + + return questionPage.map(question -> { + QuizQuestionWithSolutionDTO dto = QuizQuestionWithSolutionDTO.of(question); + return new QuizQuestionTrainingDTO(dto, true, questionIds, isNewSession); + }); + } - List 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 loadAllPracticeQuestions(Long courseId, Pageable pageable, boolean isNewSession) { + Slice questionPage = quizQuestionRepository.findAllPracticeQuizQuestionsByCourseId(courseId, pageable); - return dueQuestions; + return questionPage.map(question -> { + QuizQuestionWithSolutionDTO dto = QuizQuestionWithSolutionDTO.of(question); + return new QuizQuestionTrainingDTO(dto, false, null, isNewSession); + }); } /** @@ -233,10 +262,11 @@ 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(); @@ -244,6 +274,7 @@ public void saveProgressFromTraining(QuizQuestion question, Long userId, Submitt 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); diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizTrainingService.java b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizTrainingService.java index 34749fe8de91..29849e529415 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizTrainingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizTrainingService.java @@ -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; @@ -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); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizTrainingResource.java b/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizTrainingResource.java index c6243851e365..d6239b03a4ca 100644 --- a/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizTrainingResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/quiz/web/QuizTrainingResource.java @@ -4,6 +4,7 @@ import java.time.ZonedDateTime; import java.util.List; +import java.util.Set; import jakarta.validation.Valid; @@ -11,12 +12,16 @@ 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; @@ -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; @@ -61,22 +66,25 @@ 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> getQuizQuestionsForPractice(@PathVariable long courseId) { + @PostMapping("courses/{courseId}/training-questions") + @EnforceAtLeastStudentInCourse + public ResponseEntity> getQuizQuestionsForPractice(@PathVariable long courseId, Pageable pageable, @RequestParam boolean isNewSession, + @RequestBody Set 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 quizQuestions = quizQuestionProgressService.getQuestionsForSession(courseId, user.getId()); - List quizQuestionsWithSolutions = quizQuestions.stream().map(QuizQuestionWithSolutionDTO::of).toList(); - return ResponseEntity.ok(quizQuestionsWithSolutions); + Slice 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); } /** @@ -84,13 +92,14 @@ public ResponseEntity> getQuizQuestionsForPrac * * @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 submitForTraining(@PathVariable long courseId, @PathVariable long quizQuestionId, - @Valid @RequestBody QuizTrainingAnswerDTO submittedAnswer) { + public ResponseEntity 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(); @@ -98,7 +107,7 @@ public ResponseEntity 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, isRated, answeredAt); return ResponseEntity.ok(result); } diff --git a/src/main/resources/config/liquibase/changelog/20250920163252_changelog.xml b/src/main/resources/config/liquibase/changelog/20250920163252_changelog.xml new file mode 100644 index 000000000000..4793bc60c56d --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20250920163252_changelog.xml @@ -0,0 +1,38 @@ + + + + + + + + + + Populate course_id for existing entries + + 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; + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 668b2155f829..72a561dc42f7 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -44,6 +44,7 @@ + diff --git a/src/main/webapp/app/quiz/overview/course-training-quiz/QuizTrainingAnswer.ts b/src/main/webapp/app/quiz/overview/course-training-quiz/QuizTrainingAnswer.ts deleted file mode 100644 index 4a4b445630e3..000000000000 --- a/src/main/webapp/app/quiz/overview/course-training-quiz/QuizTrainingAnswer.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { SubmittedAnswer } from 'app/quiz/shared/entities/submitted-answer.model'; - -export class QuizTrainingAnswer { - public submittedAnswer?: SubmittedAnswer; - - constructor() {} -} diff --git a/src/main/webapp/app/quiz/overview/course-training-quiz/course-training-quiz.component.html b/src/main/webapp/app/quiz/overview/course-training-quiz/course-training-quiz.component.html index a1a3c0b9e33e..30fa180ae9ad 100644 --- a/src/main/webapp/app/quiz/overview/course-training-quiz/course-training-quiz.component.html +++ b/src/main/webapp/app/quiz/overview/course-training-quiz/course-training-quiz.component.html @@ -1,50 +1,61 @@
@if (questionsLoaded()) { - @if (questions().length > 0) { - @if (currentQuestion(); as question) { - @if (question.type === MULTIPLE_CHOICE) { - - } - @if (question.type === SHORT_ANSWER) { - - } - @if (question.type === DRAG_AND_DROP) { - - } + @if (!isRated()) { + + +
+
+ +

+ + + + + +
+ } + + @if (currentQuestion(); as question) { + @if (question.type === MULTIPLE_CHOICE) { + + } + @if (question.type === SHORT_ANSWER) { + } - @if (!submitted) { - + @if (question.type === DRAG_AND_DROP) { + } - @if (submitted) { - @if (isLastQuestion()) { - - } @else { - - } + } + @if (!submitted) { + + } + @if (submitted) { + @if (isLastQuestion()) { + + } @else { + } - } @else { - } }
diff --git a/src/main/webapp/app/quiz/overview/course-training-quiz/course-training-quiz.component.spec.ts b/src/main/webapp/app/quiz/overview/course-training-quiz/course-training-quiz.component.spec.ts index 246b2fd53808..965d728832da 100644 --- a/src/main/webapp/app/quiz/overview/course-training-quiz/course-training-quiz.component.spec.ts +++ b/src/main/webapp/app/quiz/overview/course-training-quiz/course-training-quiz.component.spec.ts @@ -17,6 +17,7 @@ import { DragAndDropQuestionComponent } from 'app/quiz/shared/questions/drag-and import { ImageComponent } from 'app/shared/image/image.component'; import { signal } from '@angular/core'; import { SubmittedAnswerAfterEvaluation } from './SubmittedAnswerAfterEvaluation'; +import { QuizQuestionTraining } from './quiz-question-training.model'; const question1: QuizQuestion = { id: 1, @@ -53,7 +54,11 @@ describe('CourseTrainingQuizComponent', () => { let fixture: ComponentFixture; let quizService: CourseTrainingQuizService; - const mockQuestions = [question1, question2, question3]; + const mockQuestions = [ + { quizQuestionWithSolutionDTO: question1, isRated: false, questionIds: [1], isNewSession: true }, + { quizQuestionWithSolutionDTO: question2, isRated: true, questionIds: [1], isNewSession: true }, + { quizQuestionWithSolutionDTO: question3, isRated: false, questionIds: [1], isNewSession: true }, + ]; beforeEach(async () => { await MockBuilder(CourseTrainingQuizComponent) @@ -73,7 +78,14 @@ describe('CourseTrainingQuizComponent', () => { }, ]); quizService = TestBed.inject(CourseTrainingQuizService); - jest.spyOn(quizService, 'getQuizQuestions').mockReturnValue(of([question1, question2, question3])); + jest.spyOn(quizService, 'getQuizQuestions').mockReturnValue( + of( + new HttpResponse({ + body: mockQuestions, + headers: { get: () => '3' } as any, + }), + ), + ); jest.spyOn(TestBed.inject(CourseManagementService), 'find').mockReturnValue(of(new HttpResponse({ body: course }))); fixture = TestBed.createComponent(CourseTrainingQuizComponent); @@ -94,11 +106,20 @@ describe('CourseTrainingQuizComponent', () => { }); it('should load questions from service', () => { - expect(component.questionsSignal()).toEqual(mockQuestions); - expect(component.questions()).toEqual(mockQuestions); + const mockResponse = new HttpResponse({ + body: mockQuestions, + headers: { get: () => '3' } as any, + }); + jest.spyOn(quizService, 'getQuizQuestionsPage').mockReturnValue(of(mockResponse)); + component.page.set(0); + component.loadQuestions(); + expect(component.allLoadedQuestions()).toHaveLength(3); + expect(component.allLoadedQuestions()[0].quizQuestionWithSolutionDTO).toEqual(question1); }); it('should check for last question', () => { + component.totalItems.set(3); + component.allLoadedQuestions.set(mockQuestions); component.currentIndex.set(0); expect(component.isLastQuestion()).toBeFalsy(); component.currentIndex.set(2); @@ -106,18 +127,20 @@ describe('CourseTrainingQuizComponent', () => { }); it('should check if questions is empty', () => { - jest.spyOn(component, 'questions').mockReturnValue([]); + component.allLoadedQuestions.set([]); component.currentIndex.set(1); expect(component.isLastQuestion()).toBeTruthy(); expect(component.currentQuestion()).toBeUndefined(); }); it('should return the current question based on currentIndex', () => { + component.allLoadedQuestions.set(mockQuestions); component.currentIndex.set(0); expect(component.currentQuestion()).toBe(question1); }); it('should go to the next question and call initQuestion', () => { + component.allLoadedQuestions.set(mockQuestions); component.currentIndex.set(0); const initQuestionSpy = jest.spyOn(component, 'initQuestion'); component.nextQuestion(); @@ -126,6 +149,39 @@ describe('CourseTrainingQuizComponent', () => { expect(initQuestionSpy).toHaveBeenCalledWith(question2); }); + it('should increment page and call loadQuestions when hasNext is true', () => { + component.page.set(0); + component.hasNext.set(true); + const loadQuestionsSpy = jest.spyOn(component, 'loadQuestions'); + + component.loadNextPage(); + + expect(component.page()).toBe(1); + expect(loadQuestionsSpy).toHaveBeenCalled(); + }); + + it('should not increment page or call loadQuestions when hasNext is false', () => { + component.page.set(0); + component.hasNext.set(false); + const loadQuestionsSpy = jest.spyOn(component, 'loadQuestions'); + + component.loadNextPage(); + + expect(component.page()).toBe(0); + expect(loadQuestionsSpy).not.toHaveBeenCalled(); + }); + + it('should set allQuestionsLoaded to true when response body is empty', () => { + const mockResponse = new HttpResponse({ + body: [], + headers: { get: () => '0' } as any, + }); + jest.spyOn(quizService, 'getQuizQuestionsPage').mockReturnValue(of(mockResponse)); + + component.loadQuestions(); + expect(component.hasNext()).toBeFalsy(); + }); + it('should init the current question', () => { component.initQuestion(question1); expect(component.showingResult).toBeFalsy(); @@ -142,7 +198,7 @@ describe('CourseTrainingQuizComponent', () => { const submitSpy = jest.spyOn(TestBed.inject(CourseTrainingQuizService), 'submitForTraining').mockReturnValue(of(new HttpResponse({ body: answer }))); const showResultSpy = jest.spyOn(component, 'applyEvaluatedAnswer'); // Drag and Drop - jest.spyOn(component, 'currentQuestion').mockReturnValue({ ...question1, exerciseId: 1 } as any); + jest.spyOn(component, 'currentQuestion').mockReturnValue(question1); component.currentIndex.set(0); component.onSubmit(); expect(submitSpy).toHaveBeenCalledOnce(); @@ -150,7 +206,7 @@ describe('CourseTrainingQuizComponent', () => { expect(showResultSpy).toHaveBeenCalledWith(answer); jest.clearAllMocks(); // Multiple Choice - jest.spyOn(component, 'currentQuestion').mockReturnValue({ ...question2, exerciseId: 2 } as any); + jest.spyOn(component, 'currentQuestion').mockReturnValue(question2); component.currentIndex.set(1); component.onSubmit(); expect(submitSpy).toHaveBeenCalledOnce(); @@ -158,7 +214,7 @@ describe('CourseTrainingQuizComponent', () => { expect(showResultSpy).toHaveBeenCalledWith(answer); jest.clearAllMocks(); // Short Answer - jest.spyOn(component, 'currentQuestion').mockReturnValue({ ...question3, exerciseId: 3 } as any); + jest.spyOn(component, 'currentQuestion').mockReturnValue(question3); component.currentIndex.set(2); component.onSubmit(); expect(submitSpy).toHaveBeenCalledOnce(); @@ -198,4 +254,18 @@ describe('CourseTrainingQuizComponent', () => { expect(navigateSpy).toHaveBeenCalledOnce(); expect(navigateSpy).toHaveBeenCalledWith(['courses', 1, 'training']); }); + + it('should set showUnratedConfirmation to false when confirmUnratedPractice is called', () => { + component.showUnratedConfirmation = true; + component.confirmUnratedPractice(); + expect(component.showUnratedConfirmation).toBeFalse(); + }); + + it('should set showUnratedConfirmation to false and navigate to training when cancelUnratedPractice is called', () => { + const navigateSpy = jest.spyOn(component, 'navigateToTraining'); + component.showUnratedConfirmation = true; + component.cancelUnratedPractice(); + expect(component.showUnratedConfirmation).toBeFalse(); + expect(navigateSpy).toHaveBeenCalled(); + }); }); diff --git a/src/main/webapp/app/quiz/overview/course-training-quiz/course-training-quiz.component.ts b/src/main/webapp/app/quiz/overview/course-training-quiz/course-training-quiz.component.ts index 6dcee0a9282f..2b9f39a23d84 100644 --- a/src/main/webapp/app/quiz/overview/course-training-quiz/course-training-quiz.component.ts +++ b/src/main/webapp/app/quiz/overview/course-training-quiz/course-training-quiz.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, computed, effect, inject, signal } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { AlertService, AlertType } from 'app/shared/service/alert.service'; @@ -18,13 +18,15 @@ import { DragAndDropSubmittedAnswer } from 'app/quiz/shared/entities/drag-and-dr import { ShortAnswerSubmittedAnswer } from 'app/quiz/shared/entities/short-answer-submitted-answer.model'; import { roundValueSpecifiedByCourseSettings } from 'app/shared/util/utils'; import { CourseManagementService } from 'app/core/course/manage/services/course-management.service'; -import { QuizTrainingAnswer } from 'app/quiz/overview/course-training-quiz/QuizTrainingAnswer'; import { SubmittedAnswerAfterEvaluation } from 'app/quiz/overview/course-training-quiz/SubmittedAnswerAfterEvaluation'; import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { QuizQuestionTraining } from 'app/quiz/overview/course-training-quiz/quiz-question-training.model'; +import { DialogModule } from 'primeng/dialog'; +import { SubmittedAnswer } from 'app/quiz/shared/entities/submitted-answer.model'; @Component({ - selector: 'jhi-course-practice-quiz', - imports: [MultipleChoiceQuestionComponent, ShortAnswerQuestionComponent, DragAndDropQuestionComponent, ButtonComponent, TranslateDirective], + selector: 'jhi-course-training-quiz', + imports: [MultipleChoiceQuestionComponent, ShortAnswerQuestionComponent, DragAndDropQuestionComponent, ButtonComponent, TranslateDirective, DialogModule], templateUrl: './course-training-quiz.component.html', }) export class CourseTrainingQuizComponent { @@ -37,24 +39,20 @@ export class CourseTrainingQuizComponent { private router = inject(Router); private quizService = inject(CourseTrainingQuizService); - private static readonly INITIAL_QUESTIONS: QuizQuestion[] = []; - currentIndex = signal(0); private alertService = inject(AlertService); private courseService = inject(CourseManagementService); + // Pagination options + page = signal(0); + size = 20; + totalItems = signal(0); + allLoadedQuestions = signal([]); + hasNext = signal(false); + // Reactive chain for loading quiz questions based on the current route paramsSignal = toSignal(this.route.parent?.params ?? EMPTY); courseId = computed(() => this.paramsSignal()?.['courseId']); - questionsSignal = toSignal( - this.route.parent!.params.pipe( - map((p) => p['courseId'] as number | undefined), - filter((id): id is number => id !== undefined), - switchMap((id) => this.quizService.getQuizQuestions(id)), - ), - { initialValue: CourseTrainingQuizComponent.INITIAL_QUESTIONS }, - ); - questions = computed(() => this.questionsSignal()); courseSignal = toSignal( this.route.parent!.params.pipe( map((p) => p['courseId'] as number | undefined), @@ -65,42 +63,121 @@ export class CourseTrainingQuizComponent { { initialValue: undefined }, ); course = computed(() => this.courseSignal()); - questionsLoaded = computed(() => this.questionsSignal() !== CourseTrainingQuizComponent.INITIAL_QUESTIONS); + questionsLoaded = computed(() => this.allLoadedQuestions().length > 0); + nextPage = computed(() => (this.currentIndex() + 2) % this.size === 0 && this.hasNext()); - trainingAnswer = new QuizTrainingAnswer(); + submittedAnswer: SubmittedAnswer; showingResult = false; submitted = false; questionScores: number = 0; selectedAnswerOptions: AnswerOption[] = []; dragAndDropMappings: DragAndDropMapping[] = []; shortAnswerSubmittedTexts: ShortAnswerSubmittedText[] = []; + previousRatedStatus = true; + showUnratedConfirmation = false; + questionIds: number[] = []; + isNewSession = true; /** * checks if the current question is the last question */ isLastQuestion = computed(() => { - if (this.questions().length === 0) { + const questions = this.allLoadedQuestions(); + if (questions.length === 0) { return true; } - return this.currentIndex() === this.questions().length - 1; + return this.currentIndex() === this.allLoadedQuestions().length - 1; }); /** * gets the current question */ currentQuestion = computed(() => { - if (this.questions().length === 0) { + const questions = this.allLoadedQuestions(); + if (questions.length === 0) { return undefined; } - return this.questions()[this.currentIndex()]; + return questions[this.currentIndex()].quizQuestionWithSolutionDTO; }); + isRated = computed(() => { + const questions = this.allLoadedQuestions(); + if (questions.length === 0) { + return false; + } + return questions[this.currentIndex()].isRated; + }); + + constructor() { + effect(() => { + const id = this.courseId(); + if (id && this.page() === 0) { + this.loadQuestions(); + } + }); + + effect(() => { + const questionStatus = this.isRated(); + if (questionStatus !== undefined) { + this.checkRatingStatusChange(); + } + }); + + effect(() => { + if (this.nextPage()) { + this.loadNextPage(); + } + }); + } + + /** + * loads questions for the current page + */ + loadQuestions(): void { + if (!this.courseId()) { + return; + } + + this.quizService.getQuizQuestionsPage(this.courseId(), this.page(), this.size, this.questionIds, this.isNewSession).subscribe({ + next: (res: HttpResponse) => { + this.hasNext.set(res.headers.get('X-Has-Next') === 'true'); + + if (this.page() === 0 && res.body) { + this.questionIds = res.body[0].questionIds ? res.body[0].questionIds : []; + this.isNewSession = res.body[0].isNewSession; + } + + if (this.page() === 0) { + this.allLoadedQuestions.set(res.body || []); + } else { + this.allLoadedQuestions.update((current) => [...current, ...(res.body || [])]); + } + + if (this.allLoadedQuestions().length > 0 && this.currentIndex() === 0) { + this.initQuestion(this.currentQuestion()!); + } + }, + }); + } + + /** + * loads the next page of questions + */ + loadNextPage(): void { + if (!this.hasNext()) { + return; + } + this.page.update((page) => page + 1); + this.loadQuestions(); + } + /** * increments the current question index or navigates to the course practice page if the last question is reached */ nextQuestion(): void { - if (this.currentIndex() < this.questions().length - 1) { - this.currentIndex.set(this.currentIndex() + 1); + const questions = this.allLoadedQuestions(); + if (this.currentIndex() < questions.length - 1) { + this.currentIndex.update((index) => index + 1); const question = this.currentQuestion(); if (question) { this.initQuestion(question); @@ -108,6 +185,16 @@ export class CourseTrainingQuizComponent { } } + checkRatingStatusChange(): void { + const currentIsRated = this.isRated(); + + if (this.previousRatedStatus && currentIsRated === false) { + this.showUnratedConfirmation = true; + } + + this.previousRatedStatus = currentIsRated; + } + /** * initializes a new question with default values * @param question @@ -115,7 +202,7 @@ export class CourseTrainingQuizComponent { initQuestion(question: QuizQuestion): void { this.showingResult = false; this.submitted = false; - this.trainingAnswer = new QuizTrainingAnswer(); + this.checkRatingStatusChange(); if (question) { switch (question.type) { case QuizQuestionType.MULTIPLE_CHOICE: @@ -135,7 +222,6 @@ export class CourseTrainingQuizComponent { * applies the current selection to the trainingAnswer object */ applySelection() { - this.trainingAnswer.submittedAnswer = undefined; const question = this.currentQuestion(); if (!question) { return; @@ -147,7 +233,7 @@ export class CourseTrainingQuizComponent { const mcSubmittedAnswer = new MultipleChoiceSubmittedAnswer(); mcSubmittedAnswer.quizQuestion = question; mcSubmittedAnswer.selectedOptions = answerOptions; - this.trainingAnswer.submittedAnswer = mcSubmittedAnswer; + this.submittedAnswer = mcSubmittedAnswer; break; } case QuizQuestionType.DRAG_AND_DROP: { @@ -155,7 +241,7 @@ export class CourseTrainingQuizComponent { const ddSubmittedAnswer = new DragAndDropSubmittedAnswer(); ddSubmittedAnswer.quizQuestion = question; ddSubmittedAnswer.mappings = mappings; - this.trainingAnswer.submittedAnswer = ddSubmittedAnswer; + this.submittedAnswer = ddSubmittedAnswer; break; } case QuizQuestionType.SHORT_ANSWER: { @@ -163,7 +249,7 @@ export class CourseTrainingQuizComponent { const saSubmittedAnswer = new ShortAnswerSubmittedAnswer(); saSubmittedAnswer.quizQuestion = question; saSubmittedAnswer.submittedTexts = submittedTexts; - this.trainingAnswer.submittedAnswer = saSubmittedAnswer; + this.submittedAnswer = saSubmittedAnswer; break; } } @@ -182,7 +268,7 @@ export class CourseTrainingQuizComponent { return; } this.applySelection(); - this.quizService.submitForTraining(this.trainingAnswer, questionId, this.courseId()).subscribe({ + this.quizService.submitForTraining(this.submittedAnswer, questionId, this.courseId(), this.isRated()).subscribe({ next: (response: HttpResponse) => { if (response.body) { this.onSubmitSuccess(response.body); @@ -240,4 +326,13 @@ export class CourseTrainingQuizComponent { navigateToTraining(): void { this.router.navigate(['courses', this.courseId(), 'training']); } + + confirmUnratedPractice(): void { + this.showUnratedConfirmation = false; + } + + cancelUnratedPractice(): void { + this.showUnratedConfirmation = false; + this.navigateToTraining(); + } } diff --git a/src/main/webapp/app/quiz/overview/course-training-quiz/quiz-question-training.model.ts b/src/main/webapp/app/quiz/overview/course-training-quiz/quiz-question-training.model.ts new file mode 100644 index 000000000000..0ff72c26670c --- /dev/null +++ b/src/main/webapp/app/quiz/overview/course-training-quiz/quiz-question-training.model.ts @@ -0,0 +1,39 @@ +import { QuizQuestionType, ScoringType } from 'app/quiz/shared/entities/quiz-question.model'; +import { DropLocation } from 'app/quiz/shared/entities/drop-location.model'; +import { DragItem } from 'app/quiz/shared/entities/drag-item.model'; +import { DragAndDropMapping } from 'app/quiz/shared/entities/drag-and-drop-mapping.model'; +import { ShortAnswerMapping } from 'app/quiz/shared/entities/short-answer-mapping.model'; +import { ShortAnswerSpot } from 'app/quiz/shared/entities/short-answer-spot.model'; +import { ShortAnswerSolution } from 'app/quiz/shared/entities/short-answer-solution.model'; +import { AnswerOption } from 'app/quiz/shared/entities/answer-option.model'; + +export class QuizQuestionWithSolutionDTO { + public id?: number; + public title?: string; + public text?: string; + public hint?: string; + public explanation?: string; + public points?: number; + public scoringType?: ScoringType; + public randomizeOrder = true; + public invalid = false; + public type?: QuizQuestionType; + public exportQuiz = false; + public backgroundFilePath?: string; + public dropLocations?: DropLocation[]; + public dragItems?: DragItem[]; + public correctMappings?: DragAndDropMapping[] | ShortAnswerMapping[]; + public spots?: ShortAnswerSpot[]; + public solutions?: ShortAnswerSolution[]; + public similarityValue?: number; + public matchLetterCase?: boolean; + public answerOptions?: AnswerOption[]; + public singleChoice?: boolean; +} + +export class QuizQuestionTraining { + public quizQuestionWithSolutionDTO?: QuizQuestionWithSolutionDTO; + public isRated: boolean; + public questionIds: number[]; + public isNewSession: boolean; +} diff --git a/src/main/webapp/app/quiz/overview/service/course-training-quiz.service.spec.ts b/src/main/webapp/app/quiz/overview/service/course-training-quiz.service.spec.ts index 947c336cd881..6e8750f1557e 100644 --- a/src/main/webapp/app/quiz/overview/service/course-training-quiz.service.spec.ts +++ b/src/main/webapp/app/quiz/overview/service/course-training-quiz.service.spec.ts @@ -5,14 +5,15 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common import { provideHttpClient } from '@angular/common/http'; import { AccountService } from 'app/core/auth/account.service'; import { MockAccountService } from 'test/helpers/mocks/service/mock-account.service'; -import { QuizTrainingAnswer } from '../course-training-quiz/QuizTrainingAnswer'; import { SubmittedAnswerAfterEvaluation } from '../course-training-quiz/SubmittedAnswerAfterEvaluation'; +import { SubmittedAnswer } from '../../shared/entities/submitted-answer.model'; describe('CourseTrainingQuizService', () => { let service: CourseTrainingQuizService; let httpMock: HttpTestingController; let questionId: number; let courseId: number; + let isRated: boolean; beforeEach(() => { TestBed.configureTestingModule({ @@ -22,6 +23,7 @@ describe('CourseTrainingQuizService', () => { httpMock = TestBed.inject(HttpTestingController); questionId = 123; courseId = 1; + isRated = true; }); afterEach(() => { @@ -34,20 +36,20 @@ describe('CourseTrainingQuizService', () => { expect(questions).toEqual(mockQuestions); }); const req = httpMock.expectOne(`api/quiz/courses/${courseId}/training-questions`); - expect(req.request.method).toBe('GET'); + expect(req.request.method).toBe('POST'); req.flush(mockQuestions); }); it('should submit submission for training', fakeAsync(() => { - const mockTrainingAnswer = new QuizTrainingAnswer(); + const mockTrainingAnswer: SubmittedAnswer = [{}] as SubmittedAnswer; const mockAnswer = new SubmittedAnswerAfterEvaluation(); mockAnswer.scoreInPoints = 10; - service.submitForTraining(mockTrainingAnswer, questionId, courseId).subscribe((res) => { + service.submitForTraining(mockTrainingAnswer, questionId, courseId, isRated).subscribe((res) => { expect(res.body!.scoreInPoints).toBe(10); }); - const req = httpMock.expectOne(`api/quiz/courses/${courseId}/training-questions/${questionId}/submit`); + const req = httpMock.expectOne(`api/quiz/courses/${courseId}/training-questions/${questionId}/submit?isRated=true`); expect(req.request.method).toBe('POST'); req.flush(mockAnswer); tick(); diff --git a/src/main/webapp/app/quiz/overview/service/course-training-quiz.service.ts b/src/main/webapp/app/quiz/overview/service/course-training-quiz.service.ts index 2e60c8f626b8..e5227e3ab33e 100644 --- a/src/main/webapp/app/quiz/overview/service/course-training-quiz.service.ts +++ b/src/main/webapp/app/quiz/overview/service/course-training-quiz.service.ts @@ -1,9 +1,10 @@ import { Injectable, inject } from '@angular/core'; -import { HttpClient, HttpResponse } from '@angular/common/http'; +import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { QuizQuestion } from 'app/quiz/shared/entities/quiz-question.model'; -import { QuizTrainingAnswer } from 'app/quiz/overview/course-training-quiz/QuizTrainingAnswer'; import { SubmittedAnswerAfterEvaluation } from 'app/quiz/overview/course-training-quiz/SubmittedAnswerAfterEvaluation'; +import { QuizQuestionTraining } from 'app/quiz/overview/course-training-quiz/quiz-question-training.model'; +import { createRequestOption } from 'app/shared/util/request.util'; +import { SubmittedAnswer } from 'app/quiz/shared/entities/submitted-answer.model'; @Injectable({ providedIn: 'root', @@ -13,13 +14,37 @@ export class CourseTrainingQuizService { /** * Retrieves a set of quiz questions for a given course by course ID from the server and returns them as an Observable. - * @param courseId + * @param courseId The course ID for which to retrieve quiz questions. + * @param req Pagination options + * @param questionIds */ - getQuizQuestions(courseId: number): Observable { - return this.http.get(`api/quiz/courses/${courseId}/training-questions`); + getQuizQuestions(courseId: number, req?: any, questionIds?: number[]): Observable> { + const params: HttpParams = createRequestOption(req); + return this.http.post(`api/quiz/courses/${courseId}/training-questions`, questionIds, { + params, + observe: 'response', + }); } - submitForTraining(answer: QuizTrainingAnswer, questionId: number, courseId: number): Observable> { - return this.http.post(`api/quiz/courses/${courseId}/training-questions/${questionId}/submit`, answer, { observe: 'response' }); + /** + * Helper function to create request options for pagination. + * @param courseId The course ID for which to retrieve quiz questions + * @param page the page number to retrieve + * @param size the number of items per page + * @param questionIds + * @param isNewSession + */ + getQuizQuestionsPage(courseId: number, page: number, size: number, questionIds: number[], isNewSession: boolean): Observable> { + const params = { + page, + size, + isNewSession, + }; + return this.getQuizQuestions(courseId, params, questionIds); + } + + submitForTraining(answer: SubmittedAnswer, questionId: number, courseId: number, isRated: boolean): Observable> { + const params = { isRated }; + return this.http.post(`api/quiz/courses/${courseId}/training-questions/${questionId}/submit`, answer, { params, observe: 'response' }); } } diff --git a/src/main/webapp/i18n/de/quizQuestion.json b/src/main/webapp/i18n/de/quizQuestion.json index 02388ed03726..872920fd0e36 100644 --- a/src/main/webapp/i18n/de/quizQuestion.json +++ b/src/main/webapp/i18n/de/quizQuestion.json @@ -33,7 +33,8 @@ "hideSampleSolution": "Blende die Musterlösung aus", "allOptions": "Wähle bitte alle richtigen Antwortmöglichkeiten aus.", "singleOption": "Wähle bitte die richtige Antwortmöglichkeit aus.", - "noQuestionDue": "Keine Fragen fällig, schaue morgen wieder vorbei." + "unratedConfirmation": "Es gibt keine fälligen Fragen. Möchtest du trotzdem im unbewerteten Modus trainieren?", + "unratedConfirmationTitle": "Keine fälligen Fragen" } } } diff --git a/src/main/webapp/i18n/en/quizQuestion.json b/src/main/webapp/i18n/en/quizQuestion.json index 2efbaacabef0..e75d84d3b9fc 100644 --- a/src/main/webapp/i18n/en/quizQuestion.json +++ b/src/main/webapp/i18n/en/quizQuestion.json @@ -33,7 +33,8 @@ "hideSampleSolution": "Hide Sample Solution", "allOptions": "Please choose all correct answer options", "singleOption": "Please choose the correct answer option", - "noQuestionDue": "There are no questions due, come back tomorrow." + "unratedConfirmation": "There are no questions due. Do you still want to train in the unrated mode?", + "unratedConfirmationTitle": "No questions due" } } } diff --git a/src/test/java/de/tum/cit/aet/artemis/quiz/QuizQuestionProgressIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/quiz/QuizQuestionProgressIntegrationTest.java index 9af6d31027d0..124c60e151f9 100644 --- a/src/test/java/de/tum/cit/aet/artemis/quiz/QuizQuestionProgressIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/quiz/QuizQuestionProgressIntegrationTest.java @@ -10,7 +10,7 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; -import java.util.Comparator; +import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.Set; @@ -19,6 +19,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; @@ -33,7 +35,7 @@ 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.ScoringType; -import de.tum.cit.aet.artemis.quiz.dto.QuizTrainingAnswerDTO; +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.repository.QuizQuestionProgressRepository; import de.tum.cit.aet.artemis.quiz.repository.QuizQuestionRepository; @@ -83,6 +85,8 @@ class QuizQuestionProgressIntegrationTest extends AbstractSpringIntegrationIndep void setUp() { userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 0); + Course course = new Course(); + courseTestRepository.save(course); User user = userTestRepository.findOneByLogin(TEST_PREFIX + "student1").orElseThrow(); userId = user.getId(); @@ -93,6 +97,7 @@ void setUp() { quizQuestionProgress = new QuizQuestionProgress(); quizQuestionProgress.setUserId(userId); quizQuestionProgress.setQuizQuestionId(quizQuestionId); + quizQuestionProgress.setCourseId(course.getId()); QuizQuestionProgressData progressData = new QuizQuestionProgressData(); progressData.setEasinessFactor(2.5); @@ -118,7 +123,6 @@ void testSaveAndRetrieveProgress() { @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void testGetQuestionsForSession() { Course course = new Course(); - course.setId(1L); courseTestRepository.save(course); QuizExercise quizExercise = new QuizExercise(); @@ -134,6 +138,7 @@ void testGetQuestionsForSession() { QuizQuestionProgress progress = new QuizQuestionProgress(); progress.setUserId(userId); + progress.setCourseId(course.getId()); progress.setQuizQuestionId(question.getId()); QuizQuestionProgressData data = new QuizQuestionProgressData(); data.setDueDate(ZonedDateTime.now().minusDays(i)); @@ -145,16 +150,56 @@ void testGetQuestionsForSession() { quizExercise.setQuizQuestions(questions); quizExerciseTestRepository.save(quizExercise); - List result = quizQuestionProgressService.getQuestionsForSession(1L, userId); - assertThat(result.size()).isEqualTo(10); + Pageable pageable = Pageable.ofSize(10); + + Slice result = quizQuestionProgressService.getQuestionsForSession(course.getId(), userId, pageable, Set.of(questions.getFirst().getId()), false); + assertThat(result.hasNext()).isTrue(); + assertThat(result.getSize()).isEqualTo(10); + for (QuizQuestionTrainingDTO dto : result.getContent()) { + assertThat(dto.isRated()).isTrue(); + } + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetQuestionsForSessionNoDueDate() { + Course course = new Course(); + courseTestRepository.save(course); + + QuizExercise quizExercise = new QuizExercise(); + quizExercise.setCourse(course); + quizExercise.setIsOpenForPractice(true); + quizExerciseTestRepository.save(quizExercise); + + List questions = new ArrayList<>(); + + for (int i = 0; i < 12; i++) { + QuizQuestion question = quizQuestionRepository.save(new MultipleChoiceQuestion()); + questions.add(question); - List progresses = result.stream().map(q -> quizQuestionProgressRepository.findByUserIdAndQuizQuestionId(userId, q.getId()).orElseThrow()).toList(); + QuizQuestionProgress progress = new QuizQuestionProgress(); + progress.setUserId(userId); + progress.setCourseId(course.getId()); + progress.setQuizQuestionId(question.getId()); + QuizQuestionProgressData data = new QuizQuestionProgressData(); + data.setDueDate(ZonedDateTime.now().plusDays(i + 1)); + progress.setProgressJson(data); + progress.setLastAnsweredAt(ZonedDateTime.now()); + quizQuestionProgressRepository.save(progress); + } + + quizExercise.setQuizQuestions(questions); + quizExerciseTestRepository.save(quizExercise); - List dueDates = progresses.stream().map(p -> p.getProgressJson().getDueDate()).toList(); + Pageable pageable = Pageable.ofSize(10); - List sortedDueDates = new ArrayList<>(dueDates); - sortedDueDates.sort(Comparator.naturalOrder()); - assertThat(dueDates).isEqualTo(sortedDueDates); + Slice result = quizQuestionProgressService.getQuestionsForSession(course.getId(), userId, pageable, Set.of(), true); + + assertThat(result.hasNext()).isTrue(); + assertThat(result.getSize()).isEqualTo(10); + for (QuizQuestionTrainingDTO dto : result.getContent()) { + assertThat(dto.isRated()).isFalse(); + } } @Test @@ -232,15 +277,16 @@ void testSubmitForTraining() throws Exception { progress.setProgressJson(dataExisting); progress.setQuizQuestionId(mcQuestion.getId()); progress.setUserId(userId); + progress.setCourseId(course.getId()); quizQuestionProgressRepository.save(progress); MultipleChoiceSubmittedAnswer submittedAnswer = new MultipleChoiceSubmittedAnswer(); submittedAnswer.setQuizQuestion(mcQuestion); submittedAnswer.setSelectedOptions(Set.of()); - QuizTrainingAnswerDTO trainingAnswerDTO = new QuizTrainingAnswerDTO(submittedAnswer); - SubmittedAnswerAfterEvaluationDTO result = request.postWithResponseBody("/api/quiz/courses/" + course.getId() + "/training-questions/" + mcQuestion.getId() + "/submit", - trainingAnswerDTO, SubmittedAnswerAfterEvaluationDTO.class, HttpStatus.OK); + SubmittedAnswerAfterEvaluationDTO result = request.postWithResponseBody( + "/api/quiz/courses/" + course.getId() + "/training-questions/" + mcQuestion.getId() + "/submit?isRated=true", submittedAnswer, + SubmittedAnswerAfterEvaluationDTO.class, HttpStatus.OK); assertThat(result).isNotNull(); assertThat(result.multipleChoiceSubmittedAnswer()).isNotNull(); @@ -276,11 +322,13 @@ void testGetQuizQuestionsForPractice() throws Exception { quizExercise.setIsOpenForPractice(true); quizExerciseService.save(quizExercise); - List quizQuestions = request.getList("/api/quiz/courses/" + course.getId() + "/training-questions", OK, QuizQuestion.class); + List quizQuestions = Arrays.asList( + request.postWithResponseBody("/api/quiz/courses/" + course.getId() + "/training-questions?isNewSession=true", Set.of(), QuizQuestionTrainingDTO[].class, OK)); Assertions.assertThat(quizQuestions).isNotNull(); Assertions.assertThat(quizQuestions).hasSameSizeAs(quizExercise.getQuizQuestions()); - Assertions.assertThat(quizQuestions).containsAll(quizExercise.getQuizQuestions()); + Assertions.assertThat(quizQuestions.stream().map(QuizQuestionTrainingDTO::getId).toList()) + .containsAll(quizExercise.getQuizQuestions().stream().map(QuizQuestion::getId).toList()); } @Test