From 652e99434c38ea761fd80877453d3fa84d71034a Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Thu, 11 Sep 2025 14:51:30 +0200 Subject: [PATCH 01/27] Add isRated flag to track if the student trains in the rated mode or the free mode --- .../quiz/dto/QuizTrainingAnswerDTO.java | 4 +- .../dto/question/QuizQuestionTrainingDTO.java | 16 ++++++ .../service/QuizQuestionProgressService.java | 26 ++++++++-- .../quiz/service/QuizTrainingService.java | 5 +- .../quiz/web/QuizTrainingResource.java | 10 ++-- .../course-training-quiz.component.ts | 14 +++--- .../quiz-question-training.model.ts | 6 +++ ...nswer.ts => quiz-training-answer.model.ts} | 1 + .../course-training-quiz.service.spec.ts | 2 +- .../service/course-training-quiz.service.ts | 8 +-- .../QuizQuestionProgressIntegrationTest.java | 49 +++++++++++++++++-- 11 files changed, 115 insertions(+), 26 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/quiz/dto/question/QuizQuestionTrainingDTO.java create mode 100644 src/main/webapp/app/quiz/overview/course-training-quiz/quiz-question-training.model.ts rename src/main/webapp/app/quiz/overview/course-training-quiz/{QuizTrainingAnswer.ts => quiz-training-answer.model.ts} (86%) 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 index aab0be4864ed..2e7825713b4d 100644 --- 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 @@ -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) { } 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..9d13802720c7 --- /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 com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonUnwrapped; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record QuizQuestionTrainingDTO(@JsonUnwrapped QuizQuestionWithSolutionDTO quizQuestionWithSolutionDTO, boolean isRated) { + + public static QuizQuestionTrainingDTO of(QuizQuestionWithSolutionDTO quizQuestionWithSolutionDTO, boolean isRated) { + return new QuizQuestionTrainingDTO(quizQuestionWithSolutionDTO, isRated); + } + + public long getId() { + return quizQuestionWithSolutionDTO().quizQuestionBaseDTO().id(); + } +} 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..249a358fc10f 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,6 +3,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import java.time.ZonedDateTime; +import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -18,6 +19,8 @@ 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; @@ -91,7 +94,7 @@ public void updateProgressCalculations(QuizQuestionProgressData data, double sco * @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 */ - public List getQuestionsForSession(Long courseId, Long userId) { + 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); @@ -106,9 +109,26 @@ public List getQuestionsForSession(Long courseId, Long userId) { 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(); + }).sorted(Comparator.comparing(q -> dueDateMap.getOrDefault(q.getId(), now))).toList(); - return dueQuestions; + List questionsForSession; + boolean hasDueQuestions = !dueQuestions.isEmpty(); + + if (hasDueQuestions) { + questionsForSession = dueQuestions; + } + else { + questionsForSession = allQuestions.stream().collect(Collectors.collectingAndThen(Collectors.toList(), list -> { + Collections.shuffle(list); + return list; + })); + } + + return questionsForSession.stream().map(q -> { + QuizQuestionWithSolutionDTO dto = QuizQuestionWithSolutionDTO.of(q); + boolean isRated = hasDueQuestions && dueQuestions.contains(q); + return QuizQuestionTrainingDTO.of(dto, isRated); + }).toList(); } /** 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..53114db90841 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 @@ -40,13 +40,16 @@ public QuizTrainingService(QuizQuestionProgressService quizQuestionProgressServi public SubmittedAnswerAfterEvaluationDTO submitForTraining(long quizQuestionId, long userId, 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, answer, answeredAt); + } return SubmittedAnswerAfterEvaluationDTO.of(answer); } 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..bbae545b2009 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 @@ -26,9 +26,8 @@ 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.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; @@ -68,15 +67,14 @@ public QuizTrainingResource(UserRepository userRepository, CourseRepository cour */ @GetMapping("courses/{courseId}/training-questions") @EnforceAtLeastStudent - public ResponseEntity> getQuizQuestionsForPractice(@PathVariable long courseId) { + public ResponseEntity> getQuizQuestionsForPractice(@PathVariable long courseId) { 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); + List quizQuestions = quizQuestionProgressService.getQuestionsForSession(courseId, user.getId()); + return ResponseEntity.ok(quizQuestions); } /** 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..0459560e0c7b 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 @@ -18,9 +18,10 @@ 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 { QuizTrainingAnswer } from 'app/quiz/overview/course-training-quiz/quiz-training-answer.model'; 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'; @Component({ selector: 'jhi-course-practice-quiz', @@ -37,7 +38,7 @@ export class CourseTrainingQuizComponent { private router = inject(Router); private quizService = inject(CourseTrainingQuizService); - private static readonly INITIAL_QUESTIONS: QuizQuestion[] = []; + private static readonly INITIAL_QUESTIONS: QuizQuestionTraining[] = []; currentIndex = signal(0); private alertService = inject(AlertService); @@ -101,7 +102,7 @@ export class CourseTrainingQuizComponent { nextQuestion(): void { if (this.currentIndex() < this.questions().length - 1) { this.currentIndex.set(this.currentIndex() + 1); - const question = this.currentQuestion(); + const question = this.currentQuestion()?.quizQuestion; if (question) { this.initQuestion(question); } @@ -136,7 +137,7 @@ export class CourseTrainingQuizComponent { */ applySelection() { this.trainingAnswer.submittedAnswer = undefined; - const question = this.currentQuestion(); + const question = this.currentQuestion()?.quizQuestion; if (!question) { return; } @@ -173,7 +174,7 @@ export class CourseTrainingQuizComponent { * Submits the quiz for practice */ onSubmit() { - const questionId = this.currentQuestion()?.id; + const questionId = this.currentQuestion()?.quizQuestion?.id; if (!questionId) { this.alertService.addAlert({ type: AlertType.WARNING, @@ -182,6 +183,7 @@ export class CourseTrainingQuizComponent { return; } this.applySelection(); + this.trainingAnswer.isRated = this.currentQuestion()?.isRated; this.quizService.submitForTraining(this.trainingAnswer, questionId, this.courseId()).subscribe({ next: (response: HttpResponse) => { if (response.body) { @@ -218,7 +220,7 @@ export class CourseTrainingQuizComponent { * Applies the evaluated answer to the current question */ applyEvaluatedAnswer(evaluatedAnswer: SubmittedAnswerAfterEvaluation) { - const question = this.currentQuestion(); + const question = this.currentQuestion()?.quizQuestion; if (!question) return; switch (question.type) { 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..452ffe8e2ffa --- /dev/null +++ b/src/main/webapp/app/quiz/overview/course-training-quiz/quiz-question-training.model.ts @@ -0,0 +1,6 @@ +import { QuizQuestion } from 'app/quiz/shared/entities/quiz-question.model'; + +export class QuizQuestionTraining { + public quizQuestion?: QuizQuestion; + public isRated?: boolean; +} diff --git a/src/main/webapp/app/quiz/overview/course-training-quiz/QuizTrainingAnswer.ts b/src/main/webapp/app/quiz/overview/course-training-quiz/quiz-training-answer.model.ts similarity index 86% rename from src/main/webapp/app/quiz/overview/course-training-quiz/QuizTrainingAnswer.ts rename to src/main/webapp/app/quiz/overview/course-training-quiz/quiz-training-answer.model.ts index 4a4b445630e3..34af2a19f4de 100644 --- a/src/main/webapp/app/quiz/overview/course-training-quiz/QuizTrainingAnswer.ts +++ b/src/main/webapp/app/quiz/overview/course-training-quiz/quiz-training-answer.model.ts @@ -2,6 +2,7 @@ import { SubmittedAnswer } from 'app/quiz/shared/entities/submitted-answer.model export class QuizTrainingAnswer { public submittedAnswer?: SubmittedAnswer; + public isRated?: boolean; constructor() {} } 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..63364200f584 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,7 +5,7 @@ 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 { QuizTrainingAnswer } from '../course-training-quiz/quiz-training-answer.model'; import { SubmittedAnswerAfterEvaluation } from '../course-training-quiz/SubmittedAnswerAfterEvaluation'; describe('CourseTrainingQuizService', () => { 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..4036bdfda156 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,9 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient, 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 { QuizTrainingAnswer } from 'app/quiz/overview/course-training-quiz/quiz-training-answer.model'; import { SubmittedAnswerAfterEvaluation } from 'app/quiz/overview/course-training-quiz/SubmittedAnswerAfterEvaluation'; +import { QuizQuestionTraining } from 'app/quiz/overview/course-training-quiz/quiz-question-training.model'; @Injectable({ providedIn: 'root', @@ -15,8 +15,8 @@ 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 */ - getQuizQuestions(courseId: number): Observable { - return this.http.get(`api/quiz/courses/${courseId}/training-questions`); + getQuizQuestions(courseId: number): Observable { + return this.http.get(`api/quiz/courses/${courseId}/training-questions`); } submitForTraining(answer: QuizTrainingAnswer, questionId: number, courseId: number): Observable> { 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..b56575906232 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 @@ -34,6 +34,7 @@ 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; @@ -145,8 +146,12 @@ void testGetQuestionsForSession() { quizExercise.setQuizQuestions(questions); quizExerciseTestRepository.save(quizExercise); - List result = quizQuestionProgressService.getQuestionsForSession(1L, userId); - assertThat(result.size()).isEqualTo(10); + List result = quizQuestionProgressService.getQuestionsForSession(1L, userId); + assertThat(result.size()).isEqualTo(12); + + for (QuizQuestionTrainingDTO dto : result) { + assertThat(dto.isRated()).isTrue(); + } List progresses = result.stream().map(q -> quizQuestionProgressRepository.findByUserIdAndQuizQuestionId(userId, q.getId()).orElseThrow()).toList(); @@ -157,6 +162,44 @@ void testGetQuestionsForSession() { assertThat(dueDates).isEqualTo(sortedDueDates); } + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetQuestionsForSessionNoDueDate() { + Course course = new Course(); + course.setId(1L); + 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); + + QuizQuestionProgress progress = new QuizQuestionProgress(); + progress.setUserId(userId); + 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 result = quizQuestionProgressService.getQuestionsForSession(1L, userId); + assertThat(result.size()).isEqualTo(12); + for (QuizQuestionTrainingDTO dto : result) { + assertThat(dto.isRated()).isFalse(); + } + } + @Test void testCalculateBox() { assertThat(quizQuestionProgressService.calculateBox(1)).isEqualTo(1); @@ -237,7 +280,7 @@ void testSubmitForTraining() throws Exception { MultipleChoiceSubmittedAnswer submittedAnswer = new MultipleChoiceSubmittedAnswer(); submittedAnswer.setQuizQuestion(mcQuestion); submittedAnswer.setSelectedOptions(Set.of()); - QuizTrainingAnswerDTO trainingAnswerDTO = new QuizTrainingAnswerDTO(submittedAnswer); + QuizTrainingAnswerDTO trainingAnswerDTO = new QuizTrainingAnswerDTO(submittedAnswer, true); SubmittedAnswerAfterEvaluationDTO result = request.postWithResponseBody("/api/quiz/courses/" + course.getId() + "/training-questions/" + mcQuestion.getId() + "/submit", trainingAnswerDTO, SubmittedAnswerAfterEvaluationDTO.class, HttpStatus.OK); From 344b56468590e2ecc3120e33a66bd954a03c23c0 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Thu, 11 Sep 2025 15:01:40 +0200 Subject: [PATCH 02/27] fix client tests --- .../course-training-quiz.component.spec.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) 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..75298ff32063 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 @@ -43,6 +43,12 @@ const question3: QuizQuestion = { exportQuiz: false, }; +const mockTrainingQuestions = [ + { quizQuestion: question1, isRated: false }, + { quizQuestion: question2, isRated: true }, + { quizQuestion: question3, isRated: false }, +]; + const course = { id: 1, title: 'Test Course' }; const answer: SubmittedAnswerAfterEvaluation = { selectedOptions: [{ scoreInPoints: 2 }] }; @@ -73,7 +79,7 @@ describe('CourseTrainingQuizComponent', () => { }, ]); quizService = TestBed.inject(CourseTrainingQuizService); - jest.spyOn(quizService, 'getQuizQuestions').mockReturnValue(of([question1, question2, question3])); + jest.spyOn(quizService, 'getQuizQuestions').mockReturnValue(of(mockTrainingQuestions)); jest.spyOn(TestBed.inject(CourseManagementService), 'find').mockReturnValue(of(new HttpResponse({ body: course }))); fixture = TestBed.createComponent(CourseTrainingQuizComponent); @@ -94,8 +100,8 @@ describe('CourseTrainingQuizComponent', () => { }); it('should load questions from service', () => { - expect(component.questionsSignal()).toEqual(mockQuestions); - expect(component.questions()).toEqual(mockQuestions); + expect(component.questionsSignal()).toEqual(mockTrainingQuestions); + expect(component.questions()).toEqual(mockTrainingQuestions); }); it('should check for last question', () => { @@ -114,7 +120,7 @@ describe('CourseTrainingQuizComponent', () => { it('should return the current question based on currentIndex', () => { component.currentIndex.set(0); - expect(component.currentQuestion()).toBe(question1); + expect(component.currentQuestion()).toBe(mockTrainingQuestions[0]); }); it('should go to the next question and call initQuestion', () => { @@ -142,7 +148,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({ quizQuestion: question1, isRated: false, exerciseId: 1 } as any); component.currentIndex.set(0); component.onSubmit(); expect(submitSpy).toHaveBeenCalledOnce(); @@ -150,7 +156,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({ quizQuestion: question1, isRated: false, exerciseId: 1 } as any); component.currentIndex.set(1); component.onSubmit(); expect(submitSpy).toHaveBeenCalledOnce(); @@ -158,7 +164,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({ quizQuestion: question3, isRated: false, exerciseId: 3 } as any); component.currentIndex.set(2); component.onSubmit(); expect(submitSpy).toHaveBeenCalledOnce(); From 610399669c54134d0ab2c3f5e95a6ac72c02f8dc Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Sat, 13 Sep 2025 08:28:45 +0200 Subject: [PATCH 03/27] fix client tests --- .../dto/question/QuizQuestionTrainingDTO.java | 5 +- .../course-training-quiz.component.html | 100 ++++++++++-------- .../course-training-quiz.component.spec.ts | 26 +++-- .../course-training-quiz.component.ts | 55 ++++++++-- .../quiz-question-training.model.ts | 2 +- src/main/webapp/i18n/de/quizQuestion.json | 3 +- src/main/webapp/i18n/en/quizQuestion.json | 3 +- 7 files changed, 126 insertions(+), 68 deletions(-) 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 index 9d13802720c7..37baa71285ed 100644 --- 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 @@ -1,10 +1,11 @@ package de.tum.cit.aet.artemis.quiz.dto.question; +import jakarta.validation.constraints.NotNull; + import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonUnwrapped; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record QuizQuestionTrainingDTO(@JsonUnwrapped QuizQuestionWithSolutionDTO quizQuestionWithSolutionDTO, boolean isRated) { +public record QuizQuestionTrainingDTO(@NotNull QuizQuestionWithSolutionDTO quizQuestionWithSolutionDTO, boolean isRated) { public static QuizQuestionTrainingDTO of(QuizQuestionWithSolutionDTO quizQuestionWithSolutionDTO, boolean isRated) { return new QuizQuestionTrainingDTO(quizQuestionWithSolutionDTO, isRated); 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..9d9cb126b52b 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,66 @@
+ @if (showUnratedConfirmation) { + + + } + @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 (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 75298ff32063..5c1fa408f812 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 @@ -43,12 +43,6 @@ const question3: QuizQuestion = { exportQuiz: false, }; -const mockTrainingQuestions = [ - { quizQuestion: question1, isRated: false }, - { quizQuestion: question2, isRated: true }, - { quizQuestion: question3, isRated: false }, -]; - const course = { id: 1, title: 'Test Course' }; const answer: SubmittedAnswerAfterEvaluation = { selectedOptions: [{ scoreInPoints: 2 }] }; @@ -59,7 +53,11 @@ describe('CourseTrainingQuizComponent', () => { let fixture: ComponentFixture; let quizService: CourseTrainingQuizService; - const mockQuestions = [question1, question2, question3]; + const mockQuestions = [ + { quizQuestionWithSolutionDTO: question1, isRated: false }, + { quizQuestionWithSolutionDTO: question2, isRated: true }, + { quizQuestionWithSolutionDTO: question3, isRated: false }, + ]; beforeEach(async () => { await MockBuilder(CourseTrainingQuizComponent) @@ -79,7 +77,7 @@ describe('CourseTrainingQuizComponent', () => { }, ]); quizService = TestBed.inject(CourseTrainingQuizService); - jest.spyOn(quizService, 'getQuizQuestions').mockReturnValue(of(mockTrainingQuestions)); + jest.spyOn(quizService, 'getQuizQuestions').mockReturnValue(of(mockQuestions)); jest.spyOn(TestBed.inject(CourseManagementService), 'find').mockReturnValue(of(new HttpResponse({ body: course }))); fixture = TestBed.createComponent(CourseTrainingQuizComponent); @@ -100,8 +98,8 @@ describe('CourseTrainingQuizComponent', () => { }); it('should load questions from service', () => { - expect(component.questionsSignal()).toEqual(mockTrainingQuestions); - expect(component.questions()).toEqual(mockTrainingQuestions); + expect(component.questionsSignal()).toEqual(mockQuestions); + expect(component.questions()).toEqual(mockQuestions); }); it('should check for last question', () => { @@ -120,7 +118,7 @@ describe('CourseTrainingQuizComponent', () => { it('should return the current question based on currentIndex', () => { component.currentIndex.set(0); - expect(component.currentQuestion()).toBe(mockTrainingQuestions[0]); + expect(component.currentQuestion()).toBe(mockQuestions[0].quizQuestionWithSolutionDTO); }); it('should go to the next question and call initQuestion', () => { @@ -148,7 +146,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({ quizQuestion: question1, isRated: false, exerciseId: 1 } as any); + jest.spyOn(component, 'currentQuestion').mockReturnValue(question1); component.currentIndex.set(0); component.onSubmit(); expect(submitSpy).toHaveBeenCalledOnce(); @@ -156,7 +154,7 @@ describe('CourseTrainingQuizComponent', () => { expect(showResultSpy).toHaveBeenCalledWith(answer); jest.clearAllMocks(); // Multiple Choice - jest.spyOn(component, 'currentQuestion').mockReturnValue({ quizQuestion: question1, isRated: false, exerciseId: 1 } as any); + jest.spyOn(component, 'currentQuestion').mockReturnValue(question2); component.currentIndex.set(1); component.onSubmit(); expect(submitSpy).toHaveBeenCalledOnce(); @@ -164,7 +162,7 @@ describe('CourseTrainingQuizComponent', () => { expect(showResultSpy).toHaveBeenCalledWith(answer); jest.clearAllMocks(); // Short Answer - jest.spyOn(component, 'currentQuestion').mockReturnValue({ quizQuestion: question3, isRated: false, exerciseId: 3 } as any); + jest.spyOn(component, 'currentQuestion').mockReturnValue(question3); component.currentIndex.set(2); component.onSubmit(); expect(submitSpy).toHaveBeenCalledOnce(); 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 0459560e0c7b..17d8df37c16d 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'; @@ -75,6 +75,8 @@ export class CourseTrainingQuizComponent { selectedAnswerOptions: AnswerOption[] = []; dragAndDropMappings: DragAndDropMapping[] = []; shortAnswerSubmittedTexts: ShortAnswerSubmittedText[] = []; + previousRatedStatus: boolean | undefined = true; + showUnratedConfirmation = false; /** * checks if the current question is the last question @@ -93,22 +95,51 @@ export class CourseTrainingQuizComponent { if (this.questions().length === 0) { return undefined; } - return this.questions()[this.currentIndex()]; + return this.questions()[this.currentIndex()].quizQuestionWithSolutionDTO; }); + isRated = computed(() => { + if (this.questions().length === 0) { + return undefined; + } + return this.questions()[this.currentIndex()].isRated; + }); + + constructor() { + // Überwache das Laden von Fragen + effect(() => { + const questions = this.questions(); + + // Prüfe nur, wenn Fragen geladen wurden + if (questions.length > 0 && questions !== CourseTrainingQuizComponent.INITIAL_QUESTIONS) { + this.checkRatingStatusChange(); + } + }); + } + /** * 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 question = this.currentQuestion()?.quizQuestion; + const question = this.currentQuestion(); if (question) { this.initQuestion(question); } } } + checkRatingStatusChange(): void { + const currentIsRated = this.isRated(); + + if (this.previousRatedStatus === true && currentIsRated === false) { + this.showUnratedConfirmation = true; + } + + this.previousRatedStatus = currentIsRated; + } + /** * initializes a new question with default values * @param question @@ -117,6 +148,7 @@ export class CourseTrainingQuizComponent { this.showingResult = false; this.submitted = false; this.trainingAnswer = new QuizTrainingAnswer(); + this.checkRatingStatusChange(); if (question) { switch (question.type) { case QuizQuestionType.MULTIPLE_CHOICE: @@ -137,7 +169,7 @@ export class CourseTrainingQuizComponent { */ applySelection() { this.trainingAnswer.submittedAnswer = undefined; - const question = this.currentQuestion()?.quizQuestion; + const question = this.currentQuestion(); if (!question) { return; } @@ -174,7 +206,7 @@ export class CourseTrainingQuizComponent { * Submits the quiz for practice */ onSubmit() { - const questionId = this.currentQuestion()?.quizQuestion?.id; + const questionId = this.currentQuestion()?.id; if (!questionId) { this.alertService.addAlert({ type: AlertType.WARNING, @@ -183,7 +215,7 @@ export class CourseTrainingQuizComponent { return; } this.applySelection(); - this.trainingAnswer.isRated = this.currentQuestion()?.isRated; + this.trainingAnswer.isRated = this.isRated(); this.quizService.submitForTraining(this.trainingAnswer, questionId, this.courseId()).subscribe({ next: (response: HttpResponse) => { if (response.body) { @@ -220,7 +252,7 @@ export class CourseTrainingQuizComponent { * Applies the evaluated answer to the current question */ applyEvaluatedAnswer(evaluatedAnswer: SubmittedAnswerAfterEvaluation) { - const question = this.currentQuestion()?.quizQuestion; + const question = this.currentQuestion(); if (!question) return; switch (question.type) { @@ -242,4 +274,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 index 452ffe8e2ffa..5dc611cc8cb9 100644 --- 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 @@ -1,6 +1,6 @@ import { QuizQuestion } from 'app/quiz/shared/entities/quiz-question.model'; export class QuizQuestionTraining { - public quizQuestion?: QuizQuestion; + public quizQuestionWithSolutionDTO?: QuizQuestion; public isRated?: boolean; } 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" } } } From 869ef5c22f9913ac634d0a084454c7c59747e02d Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Mon, 15 Sep 2025 11:54:39 +0200 Subject: [PATCH 04/27] Remove Collection.toList --- .../artemis/quiz/service/QuizQuestionProgressService.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 249a358fc10f..aef4a3369cbb 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,6 +3,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import java.time.ZonedDateTime; +import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; @@ -88,11 +89,11 @@ 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 + * @return A list of quiz questions sorted by due date */ public List getQuestionsForSession(Long courseId, Long userId) { Set allQuestions = quizQuestionRepository.findAllQuizQuestionsByCourseId(courseId); @@ -118,7 +119,7 @@ public List getQuestionsForSession(Long courseId, Long questionsForSession = dueQuestions; } else { - questionsForSession = allQuestions.stream().collect(Collectors.collectingAndThen(Collectors.toList(), list -> { + questionsForSession = allQuestions.stream().collect(Collectors.collectingAndThen(Collectors.toCollection(ArrayList::new), list -> { Collections.shuffle(list); return list; })); From a1a84658f540f8f0277fa332a3e192d4651684d7 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Mon, 15 Sep 2025 13:33:34 +0200 Subject: [PATCH 05/27] fix test --- .../artemis/quiz/QuizQuestionProgressIntegrationTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 b56575906232..df13a25eaf84 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 @@ -319,11 +319,12 @@ 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 = request.getList("/api/quiz/courses/" + course.getId() + "/training-questions", OK, QuizQuestionTrainingDTO.class); 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 From facb594ab25e0903d3453d0ca64237a5935603d4 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Tue, 16 Sep 2025 15:34:13 +0200 Subject: [PATCH 06/27] Introduce pagination --- .../QuizQuestionProgressRepository.java | 2 + .../repository/QuizQuestionRepository.java | 6 +- .../service/QuizQuestionProgressService.java | 59 +++++----- .../quiz/web/QuizTrainingResource.java | 13 ++- .../course-training-quiz.component.ts | 102 +++++++++++++----- .../service/course-training-quiz.service.ts | 26 ++++- .../QuizQuestionProgressIntegrationTest.java | 39 ++++--- 7 files changed, 165 insertions(+), 82 deletions(-) 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..a479cc5228dd 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 @@ -21,4 +21,6 @@ public interface QuizQuestionProgressRepository extends ArtemisJpaRepository findAllByUserIdAndQuizQuestionIdIn(long userId, Set quizQuestionIds); + Set findAllByUserId(long userId); + } 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..63c540ce0c70 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.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -50,7 +52,9 @@ public interface QuizQuestionRepository extends ArtemisJpaRepository findAllQuizQuestionsByCourseId(@Param("courseId") Long courseId); + Page findAllPracticeQuizQuestionsByCourseId(@Param("courseId") Long courseId, Pageable pageable); + + Page findAllById(Set ids, Pageable pageable); @Query(""" SELECT COUNT(q) > 0 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 aef4a3369cbb..521a9cdc00e9 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,17 +3,15 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import java.time.ZonedDateTime; -import java.util.ArrayList; -import java.util.Collections; -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.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import de.tum.cit.aet.artemis.quiz.domain.QuizQuestion; @@ -95,41 +93,40 @@ public void updateProgressCalculations(QuizQuestionProgressData data, double sco * @param userId ID of the user for whom the quiz questions are to be fetched * @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); - - Map dueDateMap = progressList.stream().collect(Collectors.toMap(QuizQuestionProgress::getQuizQuestionId, progress -> { - QuizQuestionProgressData data = progress.getProgressJson(); - return (data != null && data.getDueDate() != null) ? data.getDueDate() : ZonedDateTime.now(); - })); - + public Page getQuestionsForSession(Long courseId, Long userId, Pageable pageable) { ZonedDateTime now = ZonedDateTime.now(); - 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))).toList(); + Set allProgress = quizQuestionProgressRepository.findAllByUserId(userId); - List questionsForSession; - boolean hasDueQuestions = !dueQuestions.isEmpty(); + Set dueQuestionIds = 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 (hasDueQuestions) { - questionsForSession = dueQuestions; + if (!dueQuestionIds.isEmpty()) { + return loadDueQuestions(dueQuestionIds, pageable); } else { - questionsForSession = allQuestions.stream().collect(Collectors.collectingAndThen(Collectors.toCollection(ArrayList::new), list -> { - Collections.shuffle(list); - return list; - })); + return loadAllPracticeQuestions(courseId, pageable); } + } + + private Page loadDueQuestions(Set questionIds, Pageable pageable) { + Page questionPage = quizQuestionRepository.findAllById(questionIds, pageable); + + return questionPage.map(question -> { + QuizQuestionWithSolutionDTO dto = QuizQuestionWithSolutionDTO.of(question); + return QuizQuestionTrainingDTO.of(dto, true); + }); + } + + private Page loadAllPracticeQuestions(Long courseId, Pageable pageable) { + Page questionPage = quizQuestionRepository.findAllPracticeQuizQuestionsByCourseId(courseId, pageable); - return questionsForSession.stream().map(q -> { - QuizQuestionWithSolutionDTO dto = QuizQuestionWithSolutionDTO.of(q); - boolean isRated = hasDueQuestions && dueQuestions.contains(q); - return QuizQuestionTrainingDTO.of(dto, isRated); - }).toList(); + return questionPage.map(question -> { + QuizQuestionWithSolutionDTO dto = QuizQuestionWithSolutionDTO.of(question); + return QuizQuestionTrainingDTO.of(dto, false); + }); } /** 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 bbae545b2009..8b383e20274f 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 @@ -1,6 +1,7 @@ package de.tum.cit.aet.artemis.quiz.web; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; +import static tech.jhipster.web.util.PaginationUtil.generatePaginationHttpHeaders; import java.time.ZonedDateTime; import java.util.List; @@ -11,6 +12,10 @@ import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Profile; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +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; @@ -18,6 +23,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; @@ -67,14 +73,15 @@ public QuizTrainingResource(UserRepository userRepository, CourseRepository cour */ @GetMapping("courses/{courseId}/training-questions") @EnforceAtLeastStudent - public ResponseEntity> getQuizQuestionsForPractice(@PathVariable long courseId) { + public ResponseEntity> getQuizQuestionsForPractice(@PathVariable long courseId, Pageable pageable) { 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()); - return ResponseEntity.ok(quizQuestions); + Page quizQuestionsPage = quizQuestionProgressService.getQuestionsForSession(courseId, user.getId(), pageable); + HttpHeaders headers = generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), quizQuestionsPage); + return new ResponseEntity<>(quizQuestionsPage.getContent(), headers, HttpStatus.OK); } /** 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 17d8df37c16d..99cf08c3f812 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 @@ -38,24 +38,20 @@ export class CourseTrainingQuizComponent { private router = inject(Router); private quizService = inject(CourseTrainingQuizService); - private static readonly INITIAL_QUESTIONS: QuizQuestionTraining[] = []; - currentIndex = signal(0); private alertService = inject(AlertService); private courseService = inject(CourseManagementService); + // Pagination options + page = signal(1); + size = 10; + totalItems = signal(0); + loading = signal(false); + allLoadedQuestions = signal([]); + // 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), @@ -66,7 +62,7 @@ export class CourseTrainingQuizComponent { { initialValue: undefined }, ); course = computed(() => this.courseSignal()); - questionsLoaded = computed(() => this.questionsSignal() !== CourseTrainingQuizComponent.INITIAL_QUESTIONS); + questionsLoaded = computed(() => this.allLoadedQuestions().length > 0); trainingAnswer = new QuizTrainingAnswer(); showingResult = false; @@ -82,50 +78,108 @@ export class CourseTrainingQuizComponent { * 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.totalItems() - 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()].quizQuestionWithSolutionDTO; + return questions[this.currentIndex()].quizQuestionWithSolutionDTO; }); isRated = computed(() => { - if (this.questions().length === 0) { + const questions = this.allLoadedQuestions(); + if (questions.length === 0) { return undefined; } - return this.questions()[this.currentIndex()].isRated; + return questions[this.currentIndex()].isRated; }); constructor() { - // Überwache das Laden von Fragen effect(() => { - const questions = this.questions(); + const id = this.courseId(); + if (id) { + this.loadQuestions(); + } + }); - // Prüfe nur, wenn Fragen geladen wurden - if (questions.length > 0 && questions !== CourseTrainingQuizComponent.INITIAL_QUESTIONS) { + effect(() => { + const questionStatus = this.isRated(); + if (questionStatus !== undefined) { this.checkRatingStatusChange(); } }); + + effect(() => { + const currentIndex = this.currentIndex(); + const questions = this.allLoadedQuestions(); + + if (questions.length > 0 && currentIndex >= questions.length - 2 && (this.page() + 1) * this.size > this.totalItems()) { + this.loadNextPage(); + } + }); + } + + /** + * loads questions for the current page + */ + loadQuestions(): void { + if (!this.courseId()) { + return; + } + + this.loading.set(true); + this.quizService.getQuizQuestionsPage(this.courseId(), this.page(), this.size).subscribe({ + next: (res: HttpResponse) => { + const totalCount = res.headers.get('X-Total-Count'); + this.totalItems.set(totalCount ? parseInt(totalCount, 10) : 0); + + if (this.page() === 0) { + this.allLoadedQuestions.set(res.body || []); + } else { + this.allLoadedQuestions.update((current) => [...current, ...(res.body || [])]); + } + this.loading.set(false); + + if (this.allLoadedQuestions().length > 0 && this.currentIndex() === 0) { + this.initQuestion(this.currentQuestion()!); + } + }, + }); + } + + /** + * loads the next page of questions + */ + loadNextPage(): void { + if (this.loading() || (this.page() + 1) * this.size >= this.totalItems()) { + 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); + } else if ((this.page() + 1) * this.size < this.totalItems()) { + this.loadNextPage(); } } } 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 4036bdfda156..26a77ec5e731 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 @@ -4,6 +4,7 @@ import { Observable } from 'rxjs'; import { QuizTrainingAnswer } from 'app/quiz/overview/course-training-quiz/quiz-training-answer.model'; 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'; @Injectable({ providedIn: 'root', @@ -13,10 +14,29 @@ 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 */ - getQuizQuestions(courseId: number): Observable { - return this.http.get(`api/quiz/courses/${courseId}/training-questions`); + getQuizQuestions(courseId: number, req?: any): Observable> { + const options = createRequestOption(req); + return this.http.get(`api/quiz/courses/${courseId}/training-questions`, { + params: options, + 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 + */ + getQuizQuestionsPage(courseId: number, page: number, size: number): Observable> { + const req = { + page, + size, + }; + return this.getQuizQuestions(courseId, req); } submitForTraining(answer: QuizTrainingAnswer, questionId: number, courseId: number): Observable> { 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 df13a25eaf84..1855e3ce6dc4 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,6 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; -import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.Set; @@ -146,20 +145,18 @@ void testGetQuestionsForSession() { quizExercise.setQuizQuestions(questions); quizExerciseTestRepository.save(quizExercise); - List result = quizQuestionProgressService.getQuestionsForSession(1L, userId); - assertThat(result.size()).isEqualTo(12); - - for (QuizQuestionTrainingDTO dto : result) { - assertThat(dto.isRated()).isTrue(); - } - - List progresses = result.stream().map(q -> quizQuestionProgressRepository.findByUserIdAndQuizQuestionId(userId, q.getId()).orElseThrow()).toList(); - - List dueDates = progresses.stream().map(p -> p.getProgressJson().getDueDate()).toList(); - - List sortedDueDates = new ArrayList<>(dueDates); - sortedDueDates.sort(Comparator.naturalOrder()); - assertThat(dueDates).isEqualTo(sortedDueDates); + /* + * Page result = quizQuestionProgressService.getQuestionsForSession(1L, userId, Pageable.ofSize(10)); + * //assertThat(result.size()).isEqualTo(12); + * for (QuizQuestionTrainingDTO dto : result) { + * assertThat(dto.isRated()).isTrue(); + * } + * List progresses = result.stream().map(q -> quizQuestionProgressRepository.findByUserIdAndQuizQuestionId(userId, q.getId()).orElseThrow()).toList(); + * List dueDates = progresses.stream().map(p -> p.getProgressJson().getDueDate()).toList(); + * List sortedDueDates = new ArrayList<>(dueDates); + * sortedDueDates.sort(Comparator.naturalOrder()); + * assertThat(dueDates).isEqualTo(sortedDueDates); + */ } @Test @@ -193,11 +190,13 @@ void testGetQuestionsForSessionNoDueDate() { quizExercise.setQuizQuestions(questions); quizExerciseTestRepository.save(quizExercise); - List result = quizQuestionProgressService.getQuestionsForSession(1L, userId); - assertThat(result.size()).isEqualTo(12); - for (QuizQuestionTrainingDTO dto : result) { - assertThat(dto.isRated()).isFalse(); - } + /* + * Page result = quizQuestionProgressService.getQuestionsForSession(1L, userId, Pageable.ofSize(10)); + * //assertThat(result.size()).isEqualTo(12); + * for (QuizQuestionTrainingDTO dto : result) { + * assertThat(dto.isRated()).isFalse(); + * } + */ } @Test From 32ab02cef0f444f850aa594cb0112ef547afeb57 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Wed, 17 Sep 2025 11:30:09 +0200 Subject: [PATCH 07/27] test pagination --- .../QuizQuestionProgressIntegrationTest.java | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) 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 1855e3ce6dc4..c21babfd987c 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 @@ -18,6 +18,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.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; @@ -145,18 +147,22 @@ void testGetQuestionsForSession() { quizExercise.setQuizQuestions(questions); quizExerciseTestRepository.save(quizExercise); - /* - * Page result = quizQuestionProgressService.getQuestionsForSession(1L, userId, Pageable.ofSize(10)); - * //assertThat(result.size()).isEqualTo(12); - * for (QuizQuestionTrainingDTO dto : result) { - * assertThat(dto.isRated()).isTrue(); - * } - * List progresses = result.stream().map(q -> quizQuestionProgressRepository.findByUserIdAndQuizQuestionId(userId, q.getId()).orElseThrow()).toList(); - * List dueDates = progresses.stream().map(p -> p.getProgressJson().getDueDate()).toList(); - * List sortedDueDates = new ArrayList<>(dueDates); - * sortedDueDates.sort(Comparator.naturalOrder()); - * assertThat(dueDates).isEqualTo(sortedDueDates); - */ + Pageable pageable = Pageable.ofSize(10); + + Page result = quizQuestionProgressService.getQuestionsForSession(1L, userId, pageable); + assertThat(result.getTotalElements()).isEqualTo(12); + assertThat(result.getSize()).isEqualTo(10); + assertThat(result.getTotalPages()).isEqualTo(2); + for (QuizQuestionTrainingDTO dto : result.getContent()) { + assertThat(dto.isRated()).isTrue(); + } + + List progresses = result.getContent().stream().map(q -> quizQuestionProgressRepository.findByUserIdAndQuizQuestionId(userId, q.getId()).orElseThrow()) + .toList(); + List dueDates = progresses.stream().map(p -> p.getProgressJson().getDueDate()).toList(); + List sortedDueDates = new ArrayList<>(dueDates); + sortedDueDates.sort(java.util.Comparator.naturalOrder()); + assertThat(dueDates).isEqualTo(sortedDueDates); } @Test @@ -190,13 +196,16 @@ void testGetQuestionsForSessionNoDueDate() { quizExercise.setQuizQuestions(questions); quizExerciseTestRepository.save(quizExercise); - /* - * Page result = quizQuestionProgressService.getQuestionsForSession(1L, userId, Pageable.ofSize(10)); - * //assertThat(result.size()).isEqualTo(12); - * for (QuizQuestionTrainingDTO dto : result) { - * assertThat(dto.isRated()).isFalse(); - * } - */ + Pageable pageable = Pageable.ofSize(10); + + Page result = quizQuestionProgressService.getQuestionsForSession(1L, userId, pageable); + + assertThat(result.getTotalElements()).isEqualTo(12); + assertThat(result.getSize()).isEqualTo(10); + assertThat(result.getTotalPages()).isEqualTo(2); + for (QuizQuestionTrainingDTO dto : result.getContent()) { + assertThat(dto.isRated()).isFalse(); + } } @Test From 914f787deff15d7a2e3f2ff478449801d2552400 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Fri, 19 Sep 2025 15:49:52 +0200 Subject: [PATCH 08/27] introduce pagination --- .../dto/question/QuizQuestionTrainingDTO.java | 10 ++-- .../repository/QuizQuestionRepository.java | 14 +++++- .../service/QuizQuestionProgressService.java | 37 ++++++++++----- .../quiz/web/QuizTrainingResource.java | 9 ++-- .../course-training-quiz.component.html | 31 +++++------- .../course-training-quiz.component.ts | 47 ++++++++++++------- .../quiz-question-training.model.ts | 39 +++++++++++++-- .../quiz-training-answer.model.ts | 2 +- .../service/course-training-quiz.service.ts | 18 +++---- 9 files changed, 134 insertions(+), 73 deletions(-) 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 index 37baa71285ed..fda46eef53a9 100644 --- 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 @@ -1,15 +1,13 @@ package de.tum.cit.aet.artemis.quiz.dto.question; +import java.util.Set; + import jakarta.validation.constraints.NotNull; import com.fasterxml.jackson.annotation.JsonInclude; -@JsonInclude(JsonInclude.Include.NON_EMPTY) -public record QuizQuestionTrainingDTO(@NotNull QuizQuestionWithSolutionDTO quizQuestionWithSolutionDTO, boolean isRated) { - - public static QuizQuestionTrainingDTO of(QuizQuestionWithSolutionDTO quizQuestionWithSolutionDTO, boolean isRated) { - return new QuizQuestionTrainingDTO(quizQuestionWithSolutionDTO, isRated); - } +@JsonInclude(JsonInclude.Include.ALWAYS) +public record QuizQuestionTrainingDTO(@NotNull QuizQuestionWithSolutionDTO quizQuestionWithSolutionDTO, boolean isRated, Set questionIds) { public long getId() { return quizQuestionWithSolutionDTO().quizQuestionBaseDTO().id(); 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 63c540ce0c70..f819b26b7604 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 @@ -54,7 +54,12 @@ public interface QuizQuestionRepository extends ArtemisJpaRepository findAllPracticeQuizQuestionsByCourseId(@Param("courseId") Long courseId, Pageable pageable); - Page findAllById(Set ids, 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) + """) + Page findAllDueQuestions(Set ids, Long courseId, Pageable pageable); @Query(""" SELECT COUNT(q) > 0 @@ -63,6 +68,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 521a9cdc00e9..2b727705ef2c 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 @@ -7,6 +7,8 @@ import java.util.Set; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Profile; import org.springframework.dao.DataIntegrityViolationException; @@ -28,6 +30,8 @@ @Service public class QuizQuestionProgressService { + private static final Logger log = LoggerFactory.getLogger(QuizQuestionProgressService.class); + private final QuizQuestionProgressRepository quizQuestionProgressRepository; private final QuizQuestionRepository quizQuestionRepository; @@ -93,30 +97,37 @@ public void updateProgressCalculations(QuizQuestionProgressData data, double sco * @param userId ID of the user for whom the quiz questions are to be fetched * @return A list of quiz questions sorted by due date */ - public Page getQuestionsForSession(Long courseId, Long userId, Pageable pageable) { + public Page getQuestionsForSession(Long courseId, Long userId, Pageable pageable, Set questionIds) { ZonedDateTime now = ZonedDateTime.now(); + if (questionIds == null) { + Set allProgress = quizQuestionProgressRepository.findAllByUserId(userId); - Set allProgress = quizQuestionProgressRepository.findAllByUserId(userId); - - Set dueQuestionIds = allProgress.stream().filter(progress -> { - QuizQuestionProgressData data = progress.getProgressJson(); - return data != null && data.getDueDate() != null && data.getDueDate().isAfter(now); - }).map(QuizQuestionProgress::getQuizQuestionId).collect(Collectors.toSet()); + 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 (!dueQuestionIds.isEmpty()) { - return loadDueQuestions(dueQuestionIds, pageable); + if (areQuestionsDue(courseId, questionIds.size())) { + return loadDueQuestions(questionIds, courseId, pageable); } else { return loadAllPracticeQuestions(courseId, pageable); } } - private Page loadDueQuestions(Set questionIds, Pageable pageable) { - Page questionPage = quizQuestionRepository.findAllById(questionIds, pageable); + private boolean areQuestionsDue(Long courseId, int notDueCount) { + long totalQuestionsCount = quizQuestionRepository.countAllPracticeQuizQuestionsByCourseId(courseId); + + return notDueCount < totalQuestionsCount; + } + + private Page loadDueQuestions(Set questionIds, Long courseId, Pageable pageable) { + Page questionPage = quizQuestionRepository.findAllDueQuestions(questionIds, courseId, pageable); return questionPage.map(question -> { QuizQuestionWithSolutionDTO dto = QuizQuestionWithSolutionDTO.of(question); - return QuizQuestionTrainingDTO.of(dto, true); + return new QuizQuestionTrainingDTO(dto, true, questionIds); }); } @@ -125,7 +136,7 @@ private Page loadAllPracticeQuestions(Long courseId, Pa return questionPage.map(question -> { QuizQuestionWithSolutionDTO dto = QuizQuestionWithSolutionDTO.of(question); - return QuizQuestionTrainingDTO.of(dto, false); + return new QuizQuestionTrainingDTO(dto, false, null); }); } 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 8b383e20274f..3283b8d59cd8 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 @@ -5,6 +5,7 @@ import java.time.ZonedDateTime; import java.util.List; +import java.util.Set; import jakarta.validation.Valid; @@ -17,7 +18,6 @@ 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; @@ -71,15 +71,16 @@ public QuizTrainingResource(UserRepository userRepository, CourseRepository cour * @param courseId the id of the course whose quiz questions should be retrieved * @return a list of 10 quiz questions for the training session */ - @GetMapping("courses/{courseId}/training-questions") + @PostMapping("courses/{courseId}/training-questions") @EnforceAtLeastStudent - public ResponseEntity> getQuizQuestionsForPractice(@PathVariable long courseId, Pageable pageable) { + public ResponseEntity> getQuizQuestionsForPractice(@PathVariable long courseId, Pageable pageable, + @RequestBody(required = false) 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); - Page quizQuestionsPage = quizQuestionProgressService.getQuestionsForSession(courseId, user.getId(), pageable); + Page quizQuestionsPage = quizQuestionProgressService.getQuestionsForSession(courseId, user.getId(), pageable, questionIds); HttpHeaders headers = generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), quizQuestionsPage); return new ResponseEntity<>(quizQuestionsPage.getContent(), headers, HttpStatus.OK); } 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 9d9cb126b52b..00c320b3ad3d 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,23 +1,16 @@
- @if (showUnratedConfirmation) { - - - } + + +
+
+ +

+ + + + + +
@if (questionsLoaded()) { @if (currentQuestion(); as question) { 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 99cf08c3f812..40b9286c0969 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 @@ -22,10 +22,11 @@ import { QuizTrainingAnswer } from 'app/quiz/overview/course-training-quiz/quiz- 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'; @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 { @@ -43,11 +44,12 @@ export class CourseTrainingQuizComponent { private courseService = inject(CourseManagementService); // Pagination options - page = signal(1); + page = signal(0); size = 10; totalItems = signal(0); loading = signal(false); allLoadedQuestions = signal([]); + allQuestionsLoaded = signal(false); // Reactive chain for loading quiz questions based on the current route paramsSignal = toSignal(this.route.parent?.params ?? EMPTY); @@ -63,6 +65,9 @@ export class CourseTrainingQuizComponent { ); course = computed(() => this.courseSignal()); questionsLoaded = computed(() => this.allLoadedQuestions().length > 0); + nextPage = computed( + () => (this.currentIndex() + 2) % this.size === 0 && !this.loading() && !this.allQuestionsLoaded() && this.allLoadedQuestions().length <= this.currentIndex() + 2, + ); trainingAnswer = new QuizTrainingAnswer(); showingResult = false; @@ -71,8 +76,9 @@ export class CourseTrainingQuizComponent { selectedAnswerOptions: AnswerOption[] = []; dragAndDropMappings: DragAndDropMapping[] = []; shortAnswerSubmittedTexts: ShortAnswerSubmittedText[] = []; - previousRatedStatus: boolean | undefined = true; + previousRatedStatus = true; showUnratedConfirmation = false; + questionIds: number[] | undefined; /** * checks if the current question is the last question @@ -107,7 +113,7 @@ export class CourseTrainingQuizComponent { constructor() { effect(() => { const id = this.courseId(); - if (id) { + if (id && this.page() === 0) { this.loadQuestions(); } }); @@ -120,10 +126,7 @@ export class CourseTrainingQuizComponent { }); effect(() => { - const currentIndex = this.currentIndex(); - const questions = this.allLoadedQuestions(); - - if (questions.length > 0 && currentIndex >= questions.length - 2 && (this.page() + 1) * this.size > this.totalItems()) { + if (this.nextPage()) { this.loadNextPage(); } }); @@ -137,11 +140,21 @@ export class CourseTrainingQuizComponent { return; } - this.loading.set(true); - this.quizService.getQuizQuestionsPage(this.courseId(), this.page(), this.size).subscribe({ + this.quizService.getQuizQuestionsPage(this.courseId(), this.page(), this.size, this.questionIds).subscribe({ next: (res: HttpResponse) => { const totalCount = res.headers.get('X-Total-Count'); - this.totalItems.set(totalCount ? parseInt(totalCount, 10) : 0); + + this.loading.set(true); + if (this.page() === 0) { + this.questionIds = res.body?.[0]?.questionIds; + this.totalItems.set(totalCount ? parseInt(totalCount, 10) : 0); + } + + if (!res.body || res.body.length === 0) { + this.allQuestionsLoaded.set(true); + this.loading.set(false); + return; + } if (this.page() === 0) { this.allLoadedQuestions.set(res.body || []); @@ -161,7 +174,7 @@ export class CourseTrainingQuizComponent { * loads the next page of questions */ loadNextPage(): void { - if (this.loading() || (this.page() + 1) * this.size >= this.totalItems()) { + if (this.loading() || this.allQuestionsLoaded()) { return; } this.page.update((page) => page + 1); @@ -178,8 +191,6 @@ export class CourseTrainingQuizComponent { const question = this.currentQuestion(); if (question) { this.initQuestion(question); - } else if ((this.page() + 1) * this.size < this.totalItems()) { - this.loadNextPage(); } } } @@ -187,11 +198,11 @@ export class CourseTrainingQuizComponent { checkRatingStatusChange(): void { const currentIsRated = this.isRated(); - if (this.previousRatedStatus === true && currentIsRated === false) { + if (this.previousRatedStatus && currentIsRated === false) { this.showUnratedConfirmation = true; } - this.previousRatedStatus = currentIsRated; + this.previousRatedStatus = currentIsRated!; } /** @@ -269,7 +280,7 @@ export class CourseTrainingQuizComponent { return; } this.applySelection(); - this.trainingAnswer.isRated = this.isRated(); + this.trainingAnswer.isRated = this.isRated()!; this.quizService.submitForTraining(this.trainingAnswer, questionId, this.courseId()).subscribe({ next: (response: HttpResponse) => { if (response.body) { 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 index 5dc611cc8cb9..96ef22efa1c5 100644 --- 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 @@ -1,6 +1,39 @@ -import { QuizQuestion } from 'app/quiz/shared/entities/quiz-question.model'; +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?: QuizQuestion; - public isRated?: boolean; + public quizQuestionWithSolutionDTO?: QuizQuestionWithSolutionDTO; + public isRated: boolean; + public questionIds: number[]; + public id?: number; } diff --git a/src/main/webapp/app/quiz/overview/course-training-quiz/quiz-training-answer.model.ts b/src/main/webapp/app/quiz/overview/course-training-quiz/quiz-training-answer.model.ts index 34af2a19f4de..92a107ac16d9 100644 --- a/src/main/webapp/app/quiz/overview/course-training-quiz/quiz-training-answer.model.ts +++ b/src/main/webapp/app/quiz/overview/course-training-quiz/quiz-training-answer.model.ts @@ -2,7 +2,7 @@ import { SubmittedAnswer } from 'app/quiz/shared/entities/submitted-answer.model export class QuizTrainingAnswer { public submittedAnswer?: SubmittedAnswer; - public isRated?: boolean; + public isRated: boolean; constructor() {} } 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 26a77ec5e731..5525f75fa817 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,5 +1,5 @@ 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 { QuizTrainingAnswer } from 'app/quiz/overview/course-training-quiz/quiz-training-answer.model'; import { SubmittedAnswerAfterEvaluation } from 'app/quiz/overview/course-training-quiz/SubmittedAnswerAfterEvaluation'; @@ -16,11 +16,12 @@ 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 The course ID for which to retrieve quiz questions. * @param req Pagination options + * @param questionIds */ - getQuizQuestions(courseId: number, req?: any): Observable> { - const options = createRequestOption(req); - return this.http.get(`api/quiz/courses/${courseId}/training-questions`, { - params: options, + 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', }); } @@ -30,13 +31,14 @@ export class CourseTrainingQuizService { * @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 */ - getQuizQuestionsPage(courseId: number, page: number, size: number): Observable> { - const req = { + getQuizQuestionsPage(courseId: number, page: number, size: number, questionIds?: number[]): Observable> { + const params = { page, size, }; - return this.getQuizQuestions(courseId, req); + return this.getQuizQuestions(courseId, params, questionIds); } submitForTraining(answer: QuizTrainingAnswer, questionId: number, courseId: number): Observable> { From 56592a498f4d5f771f6b8da53bc2d1fadfd84614 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Sat, 20 Sep 2025 13:48:06 +0200 Subject: [PATCH 09/27] Add Test cases for new paging logic --- .../course-training-quiz.component.spec.ts | 88 +++++++++++++++++-- .../quiz-question-training.model.ts | 1 - .../course-training-quiz.service.spec.ts | 2 +- .../QuizQuestionProgressIntegrationTest.java | 20 ++--- 4 files changed, 88 insertions(+), 23 deletions(-) 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 5c1fa408f812..9600a1e20a68 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, @@ -54,9 +55,9 @@ describe('CourseTrainingQuizComponent', () => { let quizService: CourseTrainingQuizService; const mockQuestions = [ - { quizQuestionWithSolutionDTO: question1, isRated: false }, - { quizQuestionWithSolutionDTO: question2, isRated: true }, - { quizQuestionWithSolutionDTO: question3, isRated: false }, + { quizQuestionWithSolutionDTO: question1, isRated: false, questionIds: [1] }, + { quizQuestionWithSolutionDTO: question2, isRated: true, questionIds: [1] }, + { quizQuestionWithSolutionDTO: question3, isRated: false, questionIds: [1] }, ]; beforeEach(async () => { @@ -77,7 +78,14 @@ describe('CourseTrainingQuizComponent', () => { }, ]); quizService = TestBed.inject(CourseTrainingQuizService); - jest.spyOn(quizService, 'getQuizQuestions').mockReturnValue(of(mockQuestions)); + 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); @@ -98,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); @@ -110,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(mockQuestions[0].quizQuestionWithSolutionDTO); + 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(); @@ -130,6 +149,45 @@ describe('CourseTrainingQuizComponent', () => { expect(initQuestionSpy).toHaveBeenCalledWith(question2); }); + it('should increment page and call loadQuestions when not loading and not allQuestionsLoaded', () => { + component.loading.set(false); + component.allQuestionsLoaded.set(false); + component.page.set(0); + 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 loading is true', () => { + component.loading.set(true); + component.allQuestionsLoaded.set(false); + component.page.set(0); + const loadQuestionsSpy = jest.spyOn(component, 'loadQuestions'); + + component.loadNextPage(); + + expect(component.page()).toBe(0); + expect(loadQuestionsSpy).not.toHaveBeenCalled(); + }); + + it('should set allQuestionsLoaded to true and loading to false when response body is empty', () => { + const mockResponse = new HttpResponse({ + body: [], + headers: { get: () => '0' } as any, + }); + jest.spyOn(quizService, 'getQuizQuestionsPage').mockReturnValue(of(mockResponse)); + component.allQuestionsLoaded.set(false); + component.loading.set(true); + + component.loadQuestions(); + + expect(component.allQuestionsLoaded()).toBeTrue(); + expect(component.loading()).toBeFalse(); + }); + it('should init the current question', () => { component.initQuestion(question1); expect(component.showingResult).toBeFalsy(); @@ -202,4 +260,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/quiz-question-training.model.ts b/src/main/webapp/app/quiz/overview/course-training-quiz/quiz-question-training.model.ts index 96ef22efa1c5..99a6cb31f720 100644 --- 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 @@ -35,5 +35,4 @@ export class QuizQuestionTraining { public quizQuestionWithSolutionDTO?: QuizQuestionWithSolutionDTO; public isRated: boolean; public questionIds: number[]; - public id?: number; } 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 63364200f584..b36802041c92 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 @@ -34,7 +34,7 @@ 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); }); 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 c21babfd987c..3b54f5fc7697 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,6 +10,7 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.Set; @@ -120,7 +121,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(); @@ -149,27 +149,19 @@ void testGetQuestionsForSession() { Pageable pageable = Pageable.ofSize(10); - Page result = quizQuestionProgressService.getQuestionsForSession(1L, userId, pageable); - assertThat(result.getTotalElements()).isEqualTo(12); + Page result = quizQuestionProgressService.getQuestionsForSession(course.getId(), userId, pageable, Set.of(questions.getFirst().getId())); + assertThat(result.getTotalElements()).isEqualTo(11); assertThat(result.getSize()).isEqualTo(10); assertThat(result.getTotalPages()).isEqualTo(2); for (QuizQuestionTrainingDTO dto : result.getContent()) { assertThat(dto.isRated()).isTrue(); } - - List progresses = result.getContent().stream().map(q -> quizQuestionProgressRepository.findByUserIdAndQuizQuestionId(userId, q.getId()).orElseThrow()) - .toList(); - List dueDates = progresses.stream().map(p -> p.getProgressJson().getDueDate()).toList(); - List sortedDueDates = new ArrayList<>(dueDates); - sortedDueDates.sort(java.util.Comparator.naturalOrder()); - assertThat(dueDates).isEqualTo(sortedDueDates); } @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void testGetQuestionsForSessionNoDueDate() { Course course = new Course(); - course.setId(1L); courseTestRepository.save(course); QuizExercise quizExercise = new QuizExercise(); @@ -198,7 +190,7 @@ void testGetQuestionsForSessionNoDueDate() { Pageable pageable = Pageable.ofSize(10); - Page result = quizQuestionProgressService.getQuestionsForSession(1L, userId, pageable); + Page result = quizQuestionProgressService.getQuestionsForSession(course.getId(), userId, pageable, null); assertThat(result.getTotalElements()).isEqualTo(12); assertThat(result.getSize()).isEqualTo(10); @@ -206,6 +198,7 @@ void testGetQuestionsForSessionNoDueDate() { for (QuizQuestionTrainingDTO dto : result.getContent()) { assertThat(dto.isRated()).isFalse(); } + quizQuestionRepository.deleteAll(); } @Test @@ -327,7 +320,8 @@ void testGetQuizQuestionsForPractice() throws Exception { quizExercise.setIsOpenForPractice(true); quizExerciseService.save(quizExercise); - List quizQuestions = request.getList("/api/quiz/courses/" + course.getId() + "/training-questions", OK, QuizQuestionTrainingDTO.class); + List quizQuestions = Arrays + .asList(request.postWithResponseBody("/api/quiz/courses/" + course.getId() + "/training-questions", Set.of(), QuizQuestionTrainingDTO[].class, OK)); Assertions.assertThat(quizQuestions).isNotNull(); Assertions.assertThat(quizQuestions).hasSameSizeAs(quizExercise.getQuizQuestions()); From c338a354cf95204690441f1706815d5e5f6e6099 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Sat, 20 Sep 2025 14:08:17 +0200 Subject: [PATCH 10/27] Add java doc --- .../aet/artemis/quiz/repository/QuizQuestionRepository.java | 3 ++- .../artemis/quiz/service/QuizQuestionProgressService.java | 6 ++++-- .../tum/cit/aet/artemis/quiz/web/QuizTrainingResource.java | 6 ++++-- 3 files changed, 10 insertions(+), 5 deletions(-) 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 f819b26b7604..a4dbd95ec4f6 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 @@ -45,6 +45,7 @@ public interface QuizQuestionRepository extends ArtemisJpaRepository findAllDueQuestions(Set ids, Long courseId, Pageable pageable); + Page findAllDueQuestions(@Param("ids") Set ids, @Param("courseId") Long courseId, Pageable pageable); @Query(""" SELECT COUNT(q) > 0 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 2b727705ef2c..9c9ec43cde87 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 @@ -93,8 +93,10 @@ public void updateProgressCalculations(QuizQuestionProgressData data, double sco /** * 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 + * @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 Page getQuestionsForSession(Long courseId, Long userId, Pageable pageable, Set questionIds) { 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 3283b8d59cd8..f531b6707fe5 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 @@ -66,9 +66,11 @@ 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 */ @PostMapping("courses/{courseId}/training-questions") From e8361c2b813a1e4f2b9c31401ddbca37f9a25519 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Sat, 20 Sep 2025 14:18:51 +0200 Subject: [PATCH 11/27] annotate questionIDs with Nullable --- .../aet/artemis/quiz/dto/question/QuizQuestionTrainingDTO.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index fda46eef53a9..e29ef2139842 100644 --- 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 @@ -2,12 +2,13 @@ import java.util.Set; +import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.ALWAYS) -public record QuizQuestionTrainingDTO(@NotNull QuizQuestionWithSolutionDTO quizQuestionWithSolutionDTO, boolean isRated, Set questionIds) { +public record QuizQuestionTrainingDTO(@NotNull QuizQuestionWithSolutionDTO quizQuestionWithSolutionDTO, boolean isRated, @Nullable Set questionIds) { public long getId() { return quizQuestionWithSolutionDTO().quizQuestionBaseDTO().id(); From 9a396effd46c5b6dafc3181b570df29240a4537e Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Sat, 20 Sep 2025 14:29:07 +0200 Subject: [PATCH 12/27] Upper case for key words --- .../cit/aet/artemis/quiz/repository/QuizQuestionRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a4dbd95ec4f6..88404fa143cb 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 @@ -58,7 +58,7 @@ public interface QuizQuestionRepository extends ArtemisJpaRepository findAllDueQuestions(@Param("ids") Set ids, @Param("courseId") Long courseId, Pageable pageable); From 4ab77a4bcdaa51fb0f81143ab5aca999f17e4206 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Sat, 20 Sep 2025 15:11:47 +0200 Subject: [PATCH 13/27] Add json.non_empty annotation and pass -1 instead of an empty list --- .../artemis/quiz/dto/question/QuizQuestionTrainingDTO.java | 2 +- .../aet/artemis/quiz/service/QuizQuestionProgressService.java | 3 +++ .../de/tum/cit/aet/artemis/quiz/web/QuizTrainingResource.java | 4 ++++ 3 files changed, 8 insertions(+), 1 deletion(-) 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 index e29ef2139842..eeabc9ca04d5 100644 --- 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 @@ -7,7 +7,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; -@JsonInclude(JsonInclude.Include.ALWAYS) +@JsonInclude(JsonInclude.Include.NON_EMPTY) public record QuizQuestionTrainingDTO(@NotNull QuizQuestionWithSolutionDTO quizQuestionWithSolutionDTO, boolean isRated, @Nullable Set questionIds) { public long getId() { 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 9c9ec43cde87..8604050e182f 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 @@ -129,6 +129,9 @@ private Page loadDueQuestions(Set questionIds, Lo return questionPage.map(question -> { QuizQuestionWithSolutionDTO dto = QuizQuestionWithSolutionDTO.of(question); + if (questionIds.size() == 0) { + questionIds.add(-1L); + } return new QuizQuestionTrainingDTO(dto, true, questionIds); }); } 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 f531b6707fe5..01b60238bef5 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 @@ -82,6 +82,10 @@ public ResponseEntity> getQuizQuestionsForPractice Course course = courseRepository.findByIdElseThrow(courseId); authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, user); + if (questionIds != null && questionIds.contains(-1L)) { + questionIds.remove(-1L); + } + Page quizQuestionsPage = quizQuestionProgressService.getQuestionsForSession(courseId, user.getId(), pageable, questionIds); HttpHeaders headers = generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), quizQuestionsPage); return new ResponseEntity<>(quizQuestionsPage.getContent(), headers, HttpStatus.OK); From 4c48b3c2f62e8f84573385d932c8ff7f67c735f3 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Sat, 20 Sep 2025 15:24:21 +0200 Subject: [PATCH 14/27] Add sentinel earlier --- .../artemis/quiz/service/QuizQuestionProgressService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 8604050e182f..4f21ba985706 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 @@ -108,6 +108,9 @@ public Page getQuestionsForSession(Long courseId, Long 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); + } } if (areQuestionsDue(courseId, questionIds.size())) { @@ -129,9 +132,6 @@ private Page loadDueQuestions(Set questionIds, Lo return questionPage.map(question -> { QuizQuestionWithSolutionDTO dto = QuizQuestionWithSolutionDTO.of(question); - if (questionIds.size() == 0) { - questionIds.add(-1L); - } return new QuizQuestionTrainingDTO(dto, true, questionIds); }); } From a84cc47bc209ce83b6e549a3879c867b09049906 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Sat, 20 Sep 2025 15:59:06 +0200 Subject: [PATCH 15/27] Fix small bug for multiple courses --- .../artemis/quiz/repository/QuizQuestionRepository.java | 7 +++++++ .../artemis/quiz/service/QuizQuestionProgressService.java | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) 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 88404fa143cb..6fc55131c1ed 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 @@ -76,6 +76,13 @@ SELECT COUNT(q) """) long countAllPracticeQuizQuestionsByCourseId(@Param("courseId") Long courseId); + @Query(""" + SELECT COUNT(q) + FROM QuizQuestion q + WHERE q.exercise.course.id = :courseId AND q.exercise.isOpenForPractice = TRUE AND q.id IN (:ids) + """) + long countAllDueQuestionsByCourseID(@Param("courseId") Long courseId, @Param("ids") Set ids); + 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 4f21ba985706..109417e050c7 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 @@ -113,7 +113,7 @@ public Page getQuestionsForSession(Long courseId, Long } } - if (areQuestionsDue(courseId, questionIds.size())) { + if (areQuestionsDue(courseId, questionIds)) { return loadDueQuestions(questionIds, courseId, pageable); } else { @@ -121,8 +121,9 @@ public Page getQuestionsForSession(Long courseId, Long } } - private boolean areQuestionsDue(Long courseId, int notDueCount) { + private boolean areQuestionsDue(Long courseId, Set questionIds) { long totalQuestionsCount = quizQuestionRepository.countAllPracticeQuizQuestionsByCourseId(courseId); + long notDueCount = quizQuestionRepository.countAllDueQuestionsByCourseID(courseId, questionIds); return notDueCount < totalQuestionsCount; } From f51a3cee303005f7781619b4707e5b7a841dd155 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Sat, 20 Sep 2025 18:11:17 +0200 Subject: [PATCH 16/27] Add Course Id column to the progress table and allow course filtering --- .../quiz/domain/QuizQuestionProgress.java | 11 +++++++ .../QuizQuestionProgressRepository.java | 4 +-- .../repository/QuizQuestionRepository.java | 7 ----- .../service/QuizQuestionProgressService.java | 10 +++---- .../quiz/service/QuizTrainingService.java | 5 ++-- .../quiz/web/QuizTrainingResource.java | 2 +- .../changelog/20250920163252_changelog.xml | 30 +++++++++++++++++++ .../resources/config/liquibase/master.xml | 1 + .../QuizQuestionProgressIntegrationTest.java | 9 +++++- 9 files changed, 60 insertions(+), 19 deletions(-) create mode 100644 src/main/resources/config/liquibase/changelog/20250920163252_changelog.xml 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/repository/QuizQuestionProgressRepository.java b/src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizQuestionProgressRepository.java index a479cc5228dd..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,8 +19,6 @@ public interface QuizQuestionProgressRepository extends ArtemisJpaRepository findByUserIdAndQuizQuestionId(long userId, long quizQuestionId); - Set findAllByUserIdAndQuizQuestionIdIn(long userId, Set quizQuestionIds); - - Set findAllByUserId(long userId); + 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 6fc55131c1ed..88404fa143cb 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 @@ -76,13 +76,6 @@ SELECT COUNT(q) """) long countAllPracticeQuizQuestionsByCourseId(@Param("courseId") Long courseId); - @Query(""" - SELECT COUNT(q) - FROM QuizQuestion q - WHERE q.exercise.course.id = :courseId AND q.exercise.isOpenForPractice = TRUE AND q.id IN (:ids) - """) - long countAllDueQuestionsByCourseID(@Param("courseId") Long courseId, @Param("ids") Set ids); - 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 109417e050c7..32c27b4747ed 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 @@ -102,7 +102,7 @@ public void updateProgressCalculations(QuizQuestionProgressData data, double sco public Page getQuestionsForSession(Long courseId, Long userId, Pageable pageable, Set questionIds) { ZonedDateTime now = ZonedDateTime.now(); if (questionIds == null) { - Set allProgress = quizQuestionProgressRepository.findAllByUserId(userId); + Set allProgress = quizQuestionProgressRepository.findAllByUserIdAndCourseId(userId, courseId); questionIds = allProgress.stream().filter(progress -> { QuizQuestionProgressData data = progress.getProgressJson(); @@ -113,7 +113,7 @@ public Page getQuestionsForSession(Long courseId, Long } } - if (areQuestionsDue(courseId, questionIds)) { + if (areQuestionsDue(courseId, questionIds.size())) { return loadDueQuestions(questionIds, courseId, pageable); } else { @@ -121,9 +121,8 @@ public Page getQuestionsForSession(Long courseId, Long } } - private boolean areQuestionsDue(Long courseId, Set questionIds) { + private boolean areQuestionsDue(Long courseId, int notDueCount) { long totalQuestionsCount = quizQuestionRepository.countAllPracticeQuizQuestionsByCourseId(courseId); - long notDueCount = quizQuestionRepository.countAllDueQuestionsByCourseID(courseId, questionIds); return notDueCount < totalQuestionsCount; } @@ -271,7 +270,7 @@ public boolean questionsAvailableForTraining(Long courseId) { * @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(); @@ -279,6 +278,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 53114db90841..041fa6d3a6ff 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 @@ -37,7 +37,8 @@ public QuizTrainingService(QuizQuestionProgressService quizQuestionProgressServi * @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(); @@ -48,7 +49,7 @@ public SubmittedAnswerAfterEvaluationDTO submitForTraining(long quizQuestionId, answer.setQuizQuestion(quizQuestion); if (isRated) { - quizQuestionProgressService.saveProgressFromTraining(quizQuestion, userId, answer, answeredAt); + quizQuestionProgressService.saveProgressFromTraining(quizQuestion, userId, courseId, answer, answeredAt); } return SubmittedAnswerAfterEvaluationDTO.of(answer); 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 01b60238bef5..c3148077cd9f 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 @@ -110,7 +110,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, 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..eabddb42bc43 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20250920163252_changelog.xml @@ -0,0 +1,30 @@ + + + + + + + + + + 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/test/java/de/tum/cit/aet/artemis/quiz/QuizQuestionProgressIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/quiz/QuizQuestionProgressIntegrationTest.java index 3b54f5fc7697..49a7cb7c1506 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 @@ -120,6 +120,7 @@ void testSaveAndRetrieveProgress() { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void testGetQuestionsForSession() { + quizQuestionRepository.deleteAll(); Course course = new Course(); courseTestRepository.save(course); @@ -161,6 +162,7 @@ void testGetQuestionsForSession() { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void testGetQuestionsForSessionNoDueDate() { + quizQuestionRepository.deleteAll(); Course course = new Course(); courseTestRepository.save(course); @@ -177,6 +179,7 @@ void testGetQuestionsForSessionNoDueDate() { 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)); @@ -188,6 +191,11 @@ void testGetQuestionsForSessionNoDueDate() { quizExercise.setQuizQuestions(questions); quizExerciseTestRepository.save(quizExercise); + long totalQuestionsCount = quizQuestionRepository.countAllPracticeQuizQuestionsByCourseId(course.getId()); + System.out.println("Total: " + totalQuestionsCount); + Set allProgress = quizQuestionProgressRepository.findAllByUserIdAndCourseId(userId, course.getId()); + System.out.println("Progress: " + allProgress.size()); + Pageable pageable = Pageable.ofSize(10); Page result = quizQuestionProgressService.getQuestionsForSession(course.getId(), userId, pageable, null); @@ -198,7 +206,6 @@ void testGetQuestionsForSessionNoDueDate() { for (QuizQuestionTrainingDTO dto : result.getContent()) { assertThat(dto.isRated()).isFalse(); } - quizQuestionRepository.deleteAll(); } @Test From 74f15fd8fa586fc48144d8a36878c4e20b9d2685 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Sat, 20 Sep 2025 18:11:51 +0200 Subject: [PATCH 17/27] Add Course Id column to the progress table and allow course filtering --- .../artemis/quiz/QuizQuestionProgressIntegrationTest.java | 5 ----- 1 file changed, 5 deletions(-) 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 49a7cb7c1506..cb3e994b21f3 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 @@ -191,11 +191,6 @@ void testGetQuestionsForSessionNoDueDate() { quizExercise.setQuizQuestions(questions); quizExerciseTestRepository.save(quizExercise); - long totalQuestionsCount = quizQuestionRepository.countAllPracticeQuizQuestionsByCourseId(course.getId()); - System.out.println("Total: " + totalQuestionsCount); - Set allProgress = quizQuestionProgressRepository.findAllByUserIdAndCourseId(userId, course.getId()); - System.out.println("Progress: " + allProgress.size()); - Pageable pageable = Pageable.ofSize(10); Page result = quizQuestionProgressService.getQuestionsForSession(course.getId(), userId, pageable, null); From 10ae2069ef794d3bb9f42302e16e2e674f3fd2ea Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Sat, 20 Sep 2025 18:17:33 +0200 Subject: [PATCH 18/27] java doc --- .../aet/artemis/quiz/service/QuizQuestionProgressService.java | 1 + .../de/tum/cit/aet/artemis/quiz/service/QuizTrainingService.java | 1 + 2 files changed, 2 insertions(+) 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 32c27b4747ed..8c33500fb56f 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 @@ -267,6 +267,7 @@ 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 */ 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 041fa6d3a6ff..82ff2e4d9fad 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 @@ -33,6 +33,7 @@ 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 From 95c1ac17a467e895e2e95d15c46f357da0e68f88 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Sat, 20 Sep 2025 18:44:24 +0200 Subject: [PATCH 19/27] add -1 for empty question ids --- .../de/tum/cit/aet/artemis/quiz/web/QuizTrainingResource.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 c3148077cd9f..670efb232f8e 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 @@ -82,8 +82,8 @@ public ResponseEntity> getQuizQuestionsForPractice Course course = courseRepository.findByIdElseThrow(courseId); authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, user); - if (questionIds != null && questionIds.contains(-1L)) { - questionIds.remove(-1L); + if (questionIds != null && questionIds.isEmpty()) { + questionIds.add(-1L); } Page quizQuestionsPage = quizQuestionProgressService.getQuestionsForSession(courseId, user.getId(), pageable, questionIds); From bf10efe06004197e90ca5ad8328afa724ab32272 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Mon, 22 Sep 2025 12:46:41 +0200 Subject: [PATCH 20/27] Change Page to Slice and implement Code suggestions. --- .../repository/QuizQuestionRepository.java | 8 +++--- .../service/QuizQuestionProgressService.java | 16 +++++------- .../quiz/web/QuizTrainingResource.java | 16 ++++++------ .../course-training-quiz.component.html | 24 ++++++++++-------- .../course-training-quiz.component.ts | 25 ++++++------------- 5 files changed, 38 insertions(+), 51 deletions(-) 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 88404fa143cb..4b1d1b295431 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,8 +7,8 @@ import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Profile; -import org.springframework.data.domain.Page; 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; @@ -53,14 +53,14 @@ public interface QuizQuestionRepository extends ArtemisJpaRepository findAllPracticeQuizQuestionsByCourseId(@Param("courseId") Long courseId, Pageable pageable); + 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) """) - Page findAllDueQuestions(@Param("ids") Set ids, @Param("courseId") Long courseId, Pageable pageable); + Slice findAllDueQuestions(@Param("ids") Set ids, @Param("courseId") long courseId, Pageable pageable); @Query(""" SELECT COUNT(q) > 0 @@ -74,7 +74,7 @@ SELECT COUNT(q) FROM QuizQuestion q WHERE q.exercise.course.id = :courseId AND q.exercise.isOpenForPractice = TRUE """) - long countAllPracticeQuizQuestionsByCourseId(@Param("courseId") Long courseId); + 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 8c33500fb56f..5328f39b46a9 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 @@ -7,13 +7,11 @@ import java.util.Set; import java.util.stream.Collectors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Profile; import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.data.domain.Page; 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; @@ -30,8 +28,6 @@ @Service public class QuizQuestionProgressService { - private static final Logger log = LoggerFactory.getLogger(QuizQuestionProgressService.class); - private final QuizQuestionProgressRepository quizQuestionProgressRepository; private final QuizQuestionRepository quizQuestionRepository; @@ -99,7 +95,7 @@ public void updateProgressCalculations(QuizQuestionProgressData data, double sco * @param questionIds Optional set of question IDs to filter the questions * @return A list of quiz questions sorted by due date */ - public Page getQuestionsForSession(Long courseId, Long userId, Pageable pageable, Set questionIds) { + public Slice getQuestionsForSession(long courseId, long userId, Pageable pageable, Set questionIds) { ZonedDateTime now = ZonedDateTime.now(); if (questionIds == null) { Set allProgress = quizQuestionProgressRepository.findAllByUserIdAndCourseId(userId, courseId); @@ -127,8 +123,8 @@ private boolean areQuestionsDue(Long courseId, int notDueCount) { return notDueCount < totalQuestionsCount; } - private Page loadDueQuestions(Set questionIds, Long courseId, Pageable pageable) { - Page questionPage = quizQuestionRepository.findAllDueQuestions(questionIds, courseId, pageable); + private Slice loadDueQuestions(Set questionIds, Long courseId, Pageable pageable) { + Slice questionPage = quizQuestionRepository.findAllDueQuestions(questionIds, courseId, pageable); return questionPage.map(question -> { QuizQuestionWithSolutionDTO dto = QuizQuestionWithSolutionDTO.of(question); @@ -136,8 +132,8 @@ private Page loadDueQuestions(Set questionIds, Lo }); } - private Page loadAllPracticeQuestions(Long courseId, Pageable pageable) { - Page questionPage = quizQuestionRepository.findAllPracticeQuizQuestionsByCourseId(courseId, pageable); + private Slice loadAllPracticeQuestions(Long courseId, Pageable pageable) { + Slice questionPage = quizQuestionRepository.findAllPracticeQuizQuestionsByCourseId(courseId, pageable); return questionPage.map(question -> { QuizQuestionWithSolutionDTO dto = QuizQuestionWithSolutionDTO.of(question); 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 670efb232f8e..6674364843b0 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 @@ -1,7 +1,6 @@ package de.tum.cit.aet.artemis.quiz.web; import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; -import static tech.jhipster.web.util.PaginationUtil.generatePaginationHttpHeaders; import java.time.ZonedDateTime; import java.util.List; @@ -13,8 +12,8 @@ import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Profile; -import org.springframework.data.domain.Page; 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; @@ -23,7 +22,6 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import de.tum.cit.aet.artemis.core.domain.Course; import de.tum.cit.aet.artemis.core.domain.User; @@ -31,6 +29,7 @@ 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.dto.QuizTrainingAnswerDTO; import de.tum.cit.aet.artemis.quiz.dto.question.QuizQuestionTrainingDTO; @@ -74,21 +73,20 @@ public QuizTrainingResource(UserRepository userRepository, CourseRepository cour * @return a list of 10 quiz questions for the training session */ @PostMapping("courses/{courseId}/training-questions") - @EnforceAtLeastStudent + @EnforceAtLeastStudentInCourse public ResponseEntity> getQuizQuestionsForPractice(@PathVariable long courseId, Pageable pageable, @RequestBody(required = false) 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); if (questionIds != null && questionIds.isEmpty()) { questionIds.add(-1L); } - Page quizQuestionsPage = quizQuestionProgressService.getQuestionsForSession(courseId, user.getId(), pageable, questionIds); - HttpHeaders headers = generatePaginationHttpHeaders(ServletUriComponentsBuilder.fromCurrentRequest(), quizQuestionsPage); - return new ResponseEntity<>(quizQuestionsPage.getContent(), headers, HttpStatus.OK); + Slice 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); } /** 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 00c320b3ad3d..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,18 +1,20 @@
- - -
-
+ @if (questionsLoaded()) { + @if (!isRated()) { + + +
+
-

+

- - - - -
+ + + + +
+ } - @if (questionsLoaded()) { @if (currentQuestion(); as question) { @if (question.type === MULTIPLE_CHOICE) { ([]); - allQuestionsLoaded = signal(false); + hasNext = signal(false); // Reactive chain for loading quiz questions based on the current route paramsSignal = toSignal(this.route.parent?.params ?? EMPTY); @@ -65,9 +65,7 @@ export class CourseTrainingQuizComponent { ); course = computed(() => this.courseSignal()); questionsLoaded = computed(() => this.allLoadedQuestions().length > 0); - nextPage = computed( - () => (this.currentIndex() + 2) % this.size === 0 && !this.loading() && !this.allQuestionsLoaded() && this.allLoadedQuestions().length <= this.currentIndex() + 2, - ); + nextPage = computed(() => (this.currentIndex() + 2) % this.size === 0 && !this.loading() && this.hasNext()); trainingAnswer = new QuizTrainingAnswer(); showingResult = false; @@ -88,7 +86,7 @@ export class CourseTrainingQuizComponent { if (questions.length === 0) { return true; } - return this.currentIndex() === this.totalItems() - 1; + return this.currentIndex() === this.allLoadedQuestions().length - 1; }); /** @@ -105,7 +103,7 @@ export class CourseTrainingQuizComponent { isRated = computed(() => { const questions = this.allLoadedQuestions(); if (questions.length === 0) { - return undefined; + return false; } return questions[this.currentIndex()].isRated; }); @@ -142,18 +140,11 @@ export class CourseTrainingQuizComponent { this.quizService.getQuizQuestionsPage(this.courseId(), this.page(), this.size, this.questionIds).subscribe({ next: (res: HttpResponse) => { - const totalCount = res.headers.get('X-Total-Count'); + this.hasNext.set(res.headers.get('X-Has-Next') === 'true'); this.loading.set(true); if (this.page() === 0) { this.questionIds = res.body?.[0]?.questionIds; - this.totalItems.set(totalCount ? parseInt(totalCount, 10) : 0); - } - - if (!res.body || res.body.length === 0) { - this.allQuestionsLoaded.set(true); - this.loading.set(false); - return; } if (this.page() === 0) { @@ -174,7 +165,7 @@ export class CourseTrainingQuizComponent { * loads the next page of questions */ loadNextPage(): void { - if (this.loading() || this.allQuestionsLoaded()) { + if (this.loading() || !this.hasNext()) { return; } this.page.update((page) => page + 1); @@ -202,7 +193,7 @@ export class CourseTrainingQuizComponent { this.showUnratedConfirmation = true; } - this.previousRatedStatus = currentIsRated!; + this.previousRatedStatus = currentIsRated; } /** @@ -280,7 +271,7 @@ export class CourseTrainingQuizComponent { return; } this.applySelection(); - this.trainingAnswer.isRated = this.isRated()!; + this.trainingAnswer.isRated = this.isRated(); this.quizService.submitForTraining(this.trainingAnswer, questionId, this.courseId()).subscribe({ next: (response: HttpResponse) => { if (response.body) { From d470eb600ebb18b04d3f470f6ecd10dc8ef4c151 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Mon, 22 Sep 2025 12:54:25 +0200 Subject: [PATCH 21/27] Fix Test --- .../course-training-quiz.component.spec.ts | 5 +---- .../quiz/QuizQuestionProgressIntegrationTest.java | 12 +++++------- 2 files changed, 6 insertions(+), 11 deletions(-) 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 9600a1e20a68..b9d86abfa1cc 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 @@ -151,8 +151,8 @@ describe('CourseTrainingQuizComponent', () => { it('should increment page and call loadQuestions when not loading and not allQuestionsLoaded', () => { component.loading.set(false); - component.allQuestionsLoaded.set(false); component.page.set(0); + component.hasNext.set(true); const loadQuestionsSpy = jest.spyOn(component, 'loadQuestions'); component.loadNextPage(); @@ -163,7 +163,6 @@ describe('CourseTrainingQuizComponent', () => { it('should not increment page or call loadQuestions when loading is true', () => { component.loading.set(true); - component.allQuestionsLoaded.set(false); component.page.set(0); const loadQuestionsSpy = jest.spyOn(component, 'loadQuestions'); @@ -179,12 +178,10 @@ describe('CourseTrainingQuizComponent', () => { headers: { get: () => '0' } as any, }); jest.spyOn(quizService, 'getQuizQuestionsPage').mockReturnValue(of(mockResponse)); - component.allQuestionsLoaded.set(false); component.loading.set(true); component.loadQuestions(); - expect(component.allQuestionsLoaded()).toBeTrue(); expect(component.loading()).toBeFalse(); }); 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 cb3e994b21f3..546d45d8dab6 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 @@ -19,8 +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.Page; 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; @@ -150,10 +150,9 @@ void testGetQuestionsForSession() { Pageable pageable = Pageable.ofSize(10); - Page result = quizQuestionProgressService.getQuestionsForSession(course.getId(), userId, pageable, Set.of(questions.getFirst().getId())); - assertThat(result.getTotalElements()).isEqualTo(11); + Slice result = quizQuestionProgressService.getQuestionsForSession(course.getId(), userId, pageable, Set.of(questions.getFirst().getId())); + assertThat(result.hasNext()).isTrue(); assertThat(result.getSize()).isEqualTo(10); - assertThat(result.getTotalPages()).isEqualTo(2); for (QuizQuestionTrainingDTO dto : result.getContent()) { assertThat(dto.isRated()).isTrue(); } @@ -193,11 +192,10 @@ void testGetQuestionsForSessionNoDueDate() { Pageable pageable = Pageable.ofSize(10); - Page result = quizQuestionProgressService.getQuestionsForSession(course.getId(), userId, pageable, null); + Slice result = quizQuestionProgressService.getQuestionsForSession(course.getId(), userId, pageable, null); - assertThat(result.getTotalElements()).isEqualTo(12); + assertThat(result.hasNext()).isTrue(); assertThat(result.getSize()).isEqualTo(10); - assertThat(result.getTotalPages()).isEqualTo(2); for (QuizQuestionTrainingDTO dto : result.getContent()) { assertThat(dto.isRated()).isFalse(); } From e603994d1d0f5fb346142fb91baeefc9eb2806f7 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Mon, 22 Sep 2025 15:18:16 +0200 Subject: [PATCH 22/27] add rollback --- .../config/liquibase/changelog/20250920163252_changelog.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/config/liquibase/changelog/20250920163252_changelog.xml b/src/main/resources/config/liquibase/changelog/20250920163252_changelog.xml index eabddb42bc43..6cbe95b91dc0 100644 --- a/src/main/resources/config/liquibase/changelog/20250920163252_changelog.xml +++ b/src/main/resources/config/liquibase/changelog/20250920163252_changelog.xml @@ -6,6 +6,9 @@ + + + From 527e062672aaf5ba63b128cc3b6363779ab8486a Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Mon, 22 Sep 2025 16:51:49 +0200 Subject: [PATCH 23/27] Add foreign key constraint --- .../changelog/20250920163252_changelog.xml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/main/resources/config/liquibase/changelog/20250920163252_changelog.xml b/src/main/resources/config/liquibase/changelog/20250920163252_changelog.xml index 6cbe95b91dc0..4793bc60c56d 100644 --- a/src/main/resources/config/liquibase/changelog/20250920163252_changelog.xml +++ b/src/main/resources/config/liquibase/changelog/20250920163252_changelog.xml @@ -4,11 +4,8 @@ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> - + - - - @@ -25,9 +22,17 @@ - + + columnDataType="bigint"/> + + \ No newline at end of file From d48d3f772ed54003a72b0cd04ea40a8aa0ccde24 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Tue, 23 Sep 2025 11:41:30 +0200 Subject: [PATCH 24/27] Add boolean flag to track session state and refactor dto usage --- .../quiz/dto/QuizTrainingAnswerDTO.java | 11 ------ .../dto/question/QuizQuestionTrainingDTO.java | 2 +- .../service/QuizQuestionProgressService.java | 20 +++++------ .../quiz/service/QuizTrainingService.java | 26 +++++++------- .../quiz/web/QuizTrainingResource.java | 21 +++++------- .../course-training-quiz.component.spec.ts | 19 +++++------ .../course-training-quiz.component.ts | 34 ++++++++----------- .../quiz-question-training.model.ts | 1 + .../quiz-training-answer.model.ts | 8 ----- .../course-training-quiz.service.spec.ts | 10 +++--- .../service/course-training-quiz.service.ts | 11 +++--- .../QuizQuestionProgressIntegrationTest.java | 20 ++++++----- 12 files changed, 80 insertions(+), 103 deletions(-) delete mode 100644 src/main/java/de/tum/cit/aet/artemis/quiz/dto/QuizTrainingAnswerDTO.java delete mode 100644 src/main/webapp/app/quiz/overview/course-training-quiz/quiz-training-answer.model.ts 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 2e7825713b4d..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.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(@NotNull SubmittedAnswer submittedAnswer, boolean isRated) { -} 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 index eeabc9ca04d5..3e3951f021b2 100644 --- 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 @@ -8,7 +8,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record QuizQuestionTrainingDTO(@NotNull QuizQuestionWithSolutionDTO quizQuestionWithSolutionDTO, boolean isRated, @Nullable Set questionIds) { +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/service/QuizQuestionProgressService.java b/src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizQuestionProgressService.java index 5328f39b46a9..79f3b02ce74d 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 @@ -95,25 +95,23 @@ public void updateProgressCalculations(QuizQuestionProgressData data, double sco * @param questionIds Optional set of question IDs to filter the questions * @return A list of quiz questions sorted by due date */ - public Slice getQuestionsForSession(long courseId, long userId, Pageable pageable, Set questionIds) { + public Slice getQuestionsForSession(long courseId, long userId, Pageable pageable, Set questionIds, boolean isNewSession) { ZonedDateTime now = ZonedDateTime.now(); - if (questionIds == null) { + if (isNewSession) { Set allProgress = quizQuestionProgressRepository.findAllByUserIdAndCourseId(userId, courseId); 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); - } + isNewSession = false; } if (areQuestionsDue(courseId, questionIds.size())) { - return loadDueQuestions(questionIds, courseId, pageable); + return loadDueQuestions(questionIds, courseId, pageable, isNewSession); } else { - return loadAllPracticeQuestions(courseId, pageable); + return loadAllPracticeQuestions(courseId, pageable, isNewSession); } } @@ -123,21 +121,21 @@ private boolean areQuestionsDue(Long courseId, int notDueCount) { return notDueCount < totalQuestionsCount; } - private Slice loadDueQuestions(Set questionIds, Long courseId, Pageable pageable) { + 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); + return new QuizQuestionTrainingDTO(dto, true, questionIds, isNewSession); }); } - private Slice loadAllPracticeQuestions(Long courseId, Pageable pageable) { + private Slice loadAllPracticeQuestions(Long courseId, Pageable pageable, boolean isNewSession) { Slice questionPage = quizQuestionRepository.findAllPracticeQuizQuestionsByCourseId(courseId, pageable); return questionPage.map(question -> { QuizQuestionWithSolutionDTO dto = QuizQuestionWithSolutionDTO.of(question); - return new QuizQuestionTrainingDTO(dto, false, null); + return new QuizQuestionTrainingDTO(dto, false, null, isNewSession); }); } 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 82ff2e4d9fad..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,28 +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 courseId the id of the course - * @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, long courseId, QuizTrainingAnswerDTO studentSubmittedAnswer, + public SubmittedAnswerAfterEvaluationDTO submitForTraining(long quizQuestionId, long userId, long courseId, SubmittedAnswer submittedAnswer, boolean isRated, ZonedDateTime answeredAt) { QuizQuestion quizQuestion = quizQuestionRepository.findByIdElseThrow(quizQuestionId); - SubmittedAnswer answer = studentSubmittedAnswer.submittedAnswer(); - boolean isRated = studentSubmittedAnswer.isRated(); - double score = quizQuestion.scoreForAnswer(answer); + double score = quizQuestion.scoreForAnswer(submittedAnswer); - answer.setScoreInPoints(score); - answer.setQuizQuestion(quizQuestion); + submittedAnswer.setScoreInPoints(score); + submittedAnswer.setQuizQuestion(quizQuestion); if (isRated) { - quizQuestionProgressService.saveProgressFromTraining(quizQuestion, userId, courseId, answer, answeredAt); + 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 6674364843b0..fdee17116fc9 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 @@ -21,6 +21,7 @@ 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; @@ -31,7 +32,7 @@ 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.dto.QuizTrainingAnswerDTO; +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; @@ -70,20 +71,16 @@ public QuizTrainingResource(UserRepository userRepository, CourseRepository cour * @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 + * @return a list of quiz questions for the training session depending on the pagination information */ @PostMapping("courses/{courseId}/training-questions") @EnforceAtLeastStudentInCourse - public ResponseEntity> getQuizQuestionsForPractice(@PathVariable long courseId, Pageable pageable, - @RequestBody(required = false) Set questionIds) { + 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(); - if (questionIds != null && questionIds.isEmpty()) { - questionIds.add(-1L); - } - - Slice quizQuestionsSlice = quizQuestionProgressService.getQuestionsForSession(courseId, user.getId(), pageable, questionIds); + 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); @@ -99,8 +96,8 @@ public ResponseEntity> getQuizQuestionsForPractice */ @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(); @@ -108,7 +105,7 @@ public ResponseEntity submitForTraining(@Path authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, user); ZonedDateTime answeredAt = ZonedDateTime.now(); - SubmittedAnswerAfterEvaluationDTO result = quizTrainingService.submitForTraining(quizQuestionId, user.getId(), courseId, submittedAnswer, answeredAt); + SubmittedAnswerAfterEvaluationDTO result = quizTrainingService.submitForTraining(quizQuestionId, user.getId(), courseId, submittedAnswer, isRated, answeredAt); return ResponseEntity.ok(result); } 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 b9d86abfa1cc..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 @@ -55,9 +55,9 @@ describe('CourseTrainingQuizComponent', () => { let quizService: CourseTrainingQuizService; const mockQuestions = [ - { quizQuestionWithSolutionDTO: question1, isRated: false, questionIds: [1] }, - { quizQuestionWithSolutionDTO: question2, isRated: true, questionIds: [1] }, - { quizQuestionWithSolutionDTO: question3, isRated: false, questionIds: [1] }, + { 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 () => { @@ -149,8 +149,7 @@ describe('CourseTrainingQuizComponent', () => { expect(initQuestionSpy).toHaveBeenCalledWith(question2); }); - it('should increment page and call loadQuestions when not loading and not allQuestionsLoaded', () => { - component.loading.set(false); + 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'); @@ -161,9 +160,9 @@ describe('CourseTrainingQuizComponent', () => { expect(loadQuestionsSpy).toHaveBeenCalled(); }); - it('should not increment page or call loadQuestions when loading is true', () => { - component.loading.set(true); + 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(); @@ -172,17 +171,15 @@ describe('CourseTrainingQuizComponent', () => { expect(loadQuestionsSpy).not.toHaveBeenCalled(); }); - it('should set allQuestionsLoaded to true and loading to false when response body is empty', () => { + 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.loading.set(true); component.loadQuestions(); - - expect(component.loading()).toBeFalse(); + expect(component.hasNext()).toBeFalsy(); }); it('should init the current question', () => { 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 bd5f23c883ac..a696b1df5a7d 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 @@ -18,11 +18,11 @@ 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/quiz-training-answer.model'; 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-training-quiz', @@ -45,9 +45,8 @@ export class CourseTrainingQuizComponent { // Pagination options page = signal(0); - size = 10; + size = 3; // The number of questions per page is set so low for testing purposes and will be increased to 50 after successful testing totalItems = signal(0); - loading = signal(false); allLoadedQuestions = signal([]); hasNext = signal(false); @@ -65,9 +64,9 @@ export class CourseTrainingQuizComponent { ); course = computed(() => this.courseSignal()); questionsLoaded = computed(() => this.allLoadedQuestions().length > 0); - nextPage = computed(() => (this.currentIndex() + 2) % this.size === 0 && !this.loading() && this.hasNext()); + nextPage = computed(() => (this.currentIndex() + 2) % this.size === 0 && this.hasNext()); - trainingAnswer = new QuizTrainingAnswer(); + submittedAnswer: SubmittedAnswer; showingResult = false; submitted = false; questionScores: number = 0; @@ -76,7 +75,8 @@ export class CourseTrainingQuizComponent { shortAnswerSubmittedTexts: ShortAnswerSubmittedText[] = []; previousRatedStatus = true; showUnratedConfirmation = false; - questionIds: number[] | undefined; + questionIds: number[] = []; + isNewSession = true; /** * checks if the current question is the last question @@ -138,13 +138,13 @@ export class CourseTrainingQuizComponent { return; } - this.quizService.getQuizQuestionsPage(this.courseId(), this.page(), this.size, this.questionIds).subscribe({ + 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'); - this.loading.set(true); - if (this.page() === 0) { - this.questionIds = res.body?.[0]?.questionIds; + 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) { @@ -152,7 +152,6 @@ export class CourseTrainingQuizComponent { } else { this.allLoadedQuestions.update((current) => [...current, ...(res.body || [])]); } - this.loading.set(false); if (this.allLoadedQuestions().length > 0 && this.currentIndex() === 0) { this.initQuestion(this.currentQuestion()!); @@ -165,7 +164,7 @@ export class CourseTrainingQuizComponent { * loads the next page of questions */ loadNextPage(): void { - if (this.loading() || !this.hasNext()) { + if (!this.hasNext()) { return; } this.page.update((page) => page + 1); @@ -203,7 +202,6 @@ export class CourseTrainingQuizComponent { initQuestion(question: QuizQuestion): void { this.showingResult = false; this.submitted = false; - this.trainingAnswer = new QuizTrainingAnswer(); this.checkRatingStatusChange(); if (question) { switch (question.type) { @@ -224,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; @@ -236,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: { @@ -244,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: { @@ -252,7 +249,7 @@ export class CourseTrainingQuizComponent { const saSubmittedAnswer = new ShortAnswerSubmittedAnswer(); saSubmittedAnswer.quizQuestion = question; saSubmittedAnswer.submittedTexts = submittedTexts; - this.trainingAnswer.submittedAnswer = saSubmittedAnswer; + this.submittedAnswer = saSubmittedAnswer; break; } } @@ -271,8 +268,7 @@ export class CourseTrainingQuizComponent { return; } this.applySelection(); - this.trainingAnswer.isRated = this.isRated(); - 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); 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 index 99a6cb31f720..0ff72c26670c 100644 --- 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 @@ -35,4 +35,5 @@ export class QuizQuestionTraining { public quizQuestionWithSolutionDTO?: QuizQuestionWithSolutionDTO; public isRated: boolean; public questionIds: number[]; + public isNewSession: boolean; } diff --git a/src/main/webapp/app/quiz/overview/course-training-quiz/quiz-training-answer.model.ts b/src/main/webapp/app/quiz/overview/course-training-quiz/quiz-training-answer.model.ts deleted file mode 100644 index 92a107ac16d9..000000000000 --- a/src/main/webapp/app/quiz/overview/course-training-quiz/quiz-training-answer.model.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { SubmittedAnswer } from 'app/quiz/shared/entities/submitted-answer.model'; - -export class QuizTrainingAnswer { - public submittedAnswer?: SubmittedAnswer; - public isRated: boolean; - - constructor() {} -} 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 b36802041c92..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/quiz-training-answer.model'; 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(() => { @@ -39,15 +41,15 @@ describe('CourseTrainingQuizService', () => { }); 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 5525f75fa817..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,10 +1,10 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { QuizTrainingAnswer } from 'app/quiz/overview/course-training-quiz/quiz-training-answer.model'; 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', @@ -32,16 +32,19 @@ export class CourseTrainingQuizService { * @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[]): Observable> { + 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: QuizTrainingAnswer, questionId: number, courseId: number): Observable> { - return this.http.post(`api/quiz/courses/${courseId}/training-questions/${questionId}/submit`, answer, { observe: 'response' }); + 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/test/java/de/tum/cit/aet/artemis/quiz/QuizQuestionProgressIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/quiz/QuizQuestionProgressIntegrationTest.java index 546d45d8dab6..4b965113b866 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 @@ -35,7 +35,6 @@ 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; @@ -86,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(); @@ -96,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); @@ -137,6 +139,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)); @@ -150,7 +153,7 @@ void testGetQuestionsForSession() { Pageable pageable = Pageable.ofSize(10); - Slice result = quizQuestionProgressService.getQuestionsForSession(course.getId(), userId, pageable, Set.of(questions.getFirst().getId())); + 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()) { @@ -192,7 +195,7 @@ void testGetQuestionsForSessionNoDueDate() { Pageable pageable = Pageable.ofSize(10); - Slice result = quizQuestionProgressService.getQuestionsForSession(course.getId(), userId, pageable, null); + Slice result = quizQuestionProgressService.getQuestionsForSession(course.getId(), userId, pageable, Set.of(), true); assertThat(result.hasNext()).isTrue(); assertThat(result.getSize()).isEqualTo(10); @@ -276,15 +279,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, true); - 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(); @@ -320,8 +324,8 @@ void testGetQuizQuestionsForPractice() throws Exception { quizExercise.setIsOpenForPractice(true); quizExerciseService.save(quizExercise); - List quizQuestions = Arrays - .asList(request.postWithResponseBody("/api/quiz/courses/" + course.getId() + "/training-questions", Set.of(), QuizQuestionTrainingDTO[].class, OK)); + 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()); From 816e18d3ea0a35f8f77b2498fc69168573d7efb1 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Tue, 23 Sep 2025 11:51:23 +0200 Subject: [PATCH 25/27] java doc --- .../quiz/service/QuizQuestionProgressService.java | 9 +++++---- .../cit/aet/artemis/quiz/web/QuizTrainingResource.java | 8 +++++--- 2 files changed, 10 insertions(+), 7 deletions(-) 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 79f3b02ce74d..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 @@ -89,10 +89,11 @@ public void updateProgressCalculations(QuizQuestionProgressData data, double sco /** * 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 - * @param pageable Pageable object with pagination information - * @param questionIds Optional set of question IDs to filter the questions + * @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 Slice getQuestionsForSession(long courseId, long userId, Pageable pageable, Set questionIds, boolean isNewSession) { 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 fdee17116fc9..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 @@ -68,9 +68,10 @@ public QuizTrainingResource(UserRepository userRepository, CourseRepository cour /** * 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 pageable pagination information - * @param questionIds optional set of question IDs to filter the questions + * @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 */ @PostMapping("courses/{courseId}/training-questions") @@ -91,6 +92,7 @@ public ResponseEntity> getQuizQuestionsForPractice * * @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 */ From d8fea5f627f3e5ca4e94f011f7f9596c40f5dcb4 Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Tue, 23 Sep 2025 12:08:54 +0200 Subject: [PATCH 26/27] fix tests --- .../aet/artemis/quiz/QuizQuestionProgressIntegrationTest.java | 2 -- 1 file changed, 2 deletions(-) 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 4b965113b866..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 @@ -122,7 +122,6 @@ void testSaveAndRetrieveProgress() { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void testGetQuestionsForSession() { - quizQuestionRepository.deleteAll(); Course course = new Course(); courseTestRepository.save(course); @@ -164,7 +163,6 @@ void testGetQuestionsForSession() { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void testGetQuestionsForSessionNoDueDate() { - quizQuestionRepository.deleteAll(); Course course = new Course(); courseTestRepository.save(course); From 0eef4c427cad9cd70223906f67e2faba0c37127c Mon Sep 17 00:00:00 2001 From: Moritz Spengler Date: Thu, 25 Sep 2025 10:13:20 +0200 Subject: [PATCH 27/27] Set size for pagination to 20 after successful tests --- .../cit/aet/artemis/quiz/repository/QuizQuestionRepository.java | 2 +- .../course-training-quiz/course-training-quiz.component.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 4b1d1b295431..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 @@ -53,7 +53,7 @@ public interface QuizQuestionRepository extends ArtemisJpaRepository findAllPracticeQuizQuestionsByCourseId(@Param("courseId") Long courseId, Pageable pageable); + Slice findAllPracticeQuizQuestionsByCourseId(@Param("courseId") long courseId, Pageable pageable); @Query(""" SELECT q 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 a696b1df5a7d..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 @@ -45,7 +45,7 @@ export class CourseTrainingQuizComponent { // Pagination options page = signal(0); - size = 3; // The number of questions per page is set so low for testing purposes and will be increased to 50 after successful testing + size = 20; totalItems = signal(0); allLoadedQuestions = signal([]); hasNext = signal(false);