diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java index 21cfc87c6c31..be280571fbdf 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/service/ResultService.java @@ -350,9 +350,8 @@ private void filterSensitiveFeedbackInCourseExercise(Participation participation private void filterSensitiveFeedbacksInExamExercise(Participation participation, Collection results, Exercise exercise) { Exam exam = exercise.getExerciseGroup().getExam(); boolean shouldResultsBePublished = exam.resultsPublished(); - if (!shouldResultsBePublished && exam.isTestExam() && participation instanceof StudentParticipation studentParticipation) { - var participant = studentParticipation.getParticipant(); - var studentExamOptional = studentExamRepository.findByExamIdAndUserId(exam.getId(), participant.getId()); + if (!shouldResultsBePublished && exam.isTestExam() && participation instanceof StudentParticipation) { + var studentExamOptional = studentExamRepository.findByExamIdAndParticipationId(exam.getId(), participation.getId()); if (studentExamOptional.isPresent()) { shouldResultsBePublished = studentExamOptional.get().areResultsPublishedYet(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/domain/StudentExam.java b/src/main/java/de/tum/cit/aet/artemis/exam/domain/StudentExam.java index 5574f55a6727..7cb26642b1b3 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/domain/StudentExam.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/domain/StudentExam.java @@ -31,6 +31,7 @@ import de.tum.cit.aet.artemis.core.domain.AbstractAuditingEntity; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.exercise.domain.Exercise; +import de.tum.cit.aet.artemis.exercise.domain.participation.StudentParticipation; import de.tum.cit.aet.artemis.quiz.domain.QuizQuestion; @Entity @@ -84,6 +85,12 @@ public class StudentExam extends AbstractAuditingEntity { @JoinTable(name = "student_exam_quiz_question", joinColumns = @JoinColumn(name = "student_exam_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "quiz_question_id", referencedColumnName = "id")) private List quizQuestions = new ArrayList<>(); + @ManyToMany + @JoinTable(name = "student_exam_participation", joinColumns = @JoinColumn(name = "student_exam_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "participation_id", referencedColumnName = "id")) + @OrderColumn(name = "participation_order") + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) + private List studentParticipations = new ArrayList<>(); + public Boolean isSubmitted() { return submitted; } @@ -199,6 +206,14 @@ public void setQuizQuestions(List quizQuestions) { this.quizQuestions = quizQuestions; } + public List getStudentParticipations() { + return studentParticipations; + } + + public void setStudentParticipations(List studentParticipations) { + this.studentParticipations = studentParticipations; + } + /** * Adds the given exam session to the student exam * @@ -231,6 +246,16 @@ public Boolean isEnded() { return ZonedDateTime.now().isAfter(getIndividualEndDate()); } + /** + * Check if the individual student exam is finished + * A student exam is finished if it's started and either submitted or the time has passed + * + * @return true if the exam is finished, otherwise false + */ + public boolean isFinished() { + return Boolean.TRUE.equals(this.isStarted()) && (Boolean.TRUE.equals(this.isEnded()) || Boolean.TRUE.equals(this.isSubmitted())); + } + /** * Returns the individual exam end date taking the working time of this student exam into account. * For test exams, the startedDate needs to be defined as this is not equal to exam.startDate diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/repository/ExamRepository.java b/src/main/java/de/tum/cit/aet/artemis/exam/repository/ExamRepository.java index 429ce9b91e39..52d6d6053ae0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/repository/ExamRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/repository/ExamRepository.java @@ -508,4 +508,5 @@ private static Map convertListOfCountsIntoMap(List examId AND registeredUsers.user.id = :userId """) Set findActiveExams(@Param("courseIds") Set courseIds, @Param("userId") long userId, @Param("visible") ZonedDateTime visible, @Param("end") ZonedDateTime end); + } diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/repository/StudentExamRepository.java b/src/main/java/de/tum/cit/aet/artemis/exam/repository/StudentExamRepository.java index 4d3bcfe61369..9d313f15d081 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/repository/StudentExamRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/repository/StudentExamRepository.java @@ -42,15 +42,18 @@ public interface StudentExamRepository extends ArtemisJpaRepository findWithExercisesById(Long studentExamId); + @EntityGraph(type = LOAD, attributePaths = { "exercises", "studentParticipations" }) + Optional findWithExercisesAndStudentParticipationsById(Long studentExamId); + @Query(""" SELECT se FROM StudentExam se LEFT JOIN FETCH se.exercises e - LEFT JOIN FETCH e.submissionPolicy LEFT JOIN FETCH se.examSessions + LEFT JOIN FETCH se.studentParticipations WHERE se.id = :studentExamId """) - Optional findWithExercisesSubmissionPolicyAndSessionsById(@Param("studentExamId") long studentExamId); + Optional findWithExercisesAndSessionsAndStudentParticipationsById(@Param("studentExamId") long studentExamId); @Query(""" SELECT DISTINCT se @@ -190,6 +193,29 @@ SELECT COUNT(se) """) Optional findByExamIdAndUserId(@Param("examId") long examId, @Param("userId") long userId); + Optional findFirstByExamIdAndUserIdOrderByIdDesc(long examId, long userId); + + @Query(""" + SELECT se + FROM StudentExam se + JOIN se.studentParticipations p + WHERE se.exam.id = :examId + AND p.id = :participationId + """) + Optional findByExamIdAndParticipationId(@Param("examId") long examId, @Param("participationId") long participationId); + + /** + * Return the StudentExam for the given examId and userId, if possible. For test exams, the latest Student Exam is returned. + * + * @param examId id of the exam + * @param userId id of the user + * @return the student exam + * @throws EntityNotFoundException if no student exams could be found + */ + default StudentExam findOneByExamIdAndUserIdElseThrow(long examId, long userId) { + return getValueElseThrow(this.findFirstByExamIdAndUserIdOrderByIdDesc(examId, userId)); + } + /** * Checks if any StudentExam exists for the given user (student) id in the given course. * @@ -257,7 +283,17 @@ SELECT MAX(se.workingTime) AND se.exam.testExam = TRUE AND se.testRun = FALSE """) - List findStudentExamForTestExamsByUserIdAndCourseId(@Param("userId") Long userId, @Param("courseId") Long courseId); + List findStudentExamsForTestExamsByUserIdAndCourseId(@Param("userId") Long userId, @Param("courseId") Long courseId); + + @Query(""" + SELECT DISTINCT se + FROM StudentExam se + WHERE se.user.id = :userId + AND se.exam.id = :examId + AND se.exam.testExam = TRUE + AND se.testRun = FALSE + """) + List findStudentExamsForTestExamsByUserIdAndExamId(@Param("userId") Long userId, @Param("examId") Long examId); @Query(""" SELECT DISTINCT se @@ -317,15 +353,20 @@ default StudentExam findByIdWithExercisesElseThrow(Long studentExamId) { return getValueElseThrow(findWithExercisesById(studentExamId), studentExamId); } + @NotNull + default StudentExam findByIdWithExercisesAndStudentParticipationsElseThrow(Long studentExamId) { + return getValueElseThrow(findWithExercisesAndStudentParticipationsById(studentExamId)); + } + /** - * Get one student exam by id with exercises, programming exercise submission policy and sessions + * Get one student exam by id with exercises, sessions and student participations * * @param studentExamId the id of the student exam - * @return the student exam with exercises + * @return the student exam with exercises, sessions and student participations */ @NotNull - default StudentExam findByIdWithExercisesSubmissionPolicyAndSessionsElseThrow(Long studentExamId) { - return getValueElseThrow(findWithExercisesSubmissionPolicyAndSessionsById(studentExamId), studentExamId); + default StudentExam findByIdWithExercisesAndSessionsAndStudentParticipationsElseThrow(Long studentExamId) { + return getValueElseThrow(findWithExercisesAndSessionsAndStudentParticipationsById(studentExamId), studentExamId); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamAccessService.java b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamAccessService.java index ca97f86bce7c..ff552b236c7b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamAccessService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamAccessService.java @@ -3,6 +3,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; import java.time.ZonedDateTime; +import java.util.List; import java.util.Optional; import org.springframework.context.annotation.Profile; @@ -48,8 +49,11 @@ public class ExamAccessService { private final StudentExamService studentExamService; + private final ExamDateService examDateService; + public ExamAccessService(ExamRepository examRepository, StudentExamRepository studentExamRepository, AuthorizationCheckService authorizationCheckService, - UserRepository userRepository, CourseRepository courseRepository, ExamRegistrationService examRegistrationService, StudentExamService studentExamService) { + UserRepository userRepository, CourseRepository courseRepository, ExamRegistrationService examRegistrationService, StudentExamService studentExamService, + ExamDateService examDateService) { this.examRepository = examRepository; this.studentExamRepository = studentExamRepository; this.authorizationCheckService = authorizationCheckService; @@ -57,6 +61,7 @@ public ExamAccessService(ExamRepository examRepository, StudentExamRepository st this.courseRepository = courseRepository; this.examRegistrationService = examRegistrationService; this.studentExamService = studentExamService; + this.examDateService = examDateService; } /** @@ -67,21 +72,84 @@ public ExamAccessService(ExamRepository examRepository, StudentExamRepository st * @param examId The id of the exam * @return a ResponseEntity with the exam */ + public StudentExam getExamInCourseElseThrow(Long courseId, Long examId) { User currentUser = userRepository.getUserWithGroupsAndAuthorities(); - - // TODO: we should distinguish the whole method between test exam and real exam to improve the readability of the code // Check that the current user is at least student in the course. Course course = courseRepository.findByIdElseThrow(courseId); authorizationCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.STUDENT, course, currentUser); - // Check that the student exam exists - Optional optionalStudentExam = studentExamRepository.findByExamIdAndUserId(examId, currentUser.getId()); + Exam exam = examRepository.findWithExerciseGroupsAndExercisesByIdOrElseThrow(examId); + checkExamBelongsToCourseElseThrow(courseId, exam); + + if (!examId.equals(exam.getId())) { + throw new BadRequestAlertException("The provided examId does not match with the examId of the studentExam", ENTITY_NAME, "examIdMismatch"); + } + + // Check that the exam is visible + if (exam.getVisibleDate() != null && exam.getVisibleDate().isAfter(ZonedDateTime.now())) { + throw new AccessForbiddenException(ENTITY_NAME, examId); + } + if (exam.isTestExam()) { + return getOrGenerateTestExam(exam, course, currentUser); + } + else { + return getOrGenerateNormalExam(examId, currentUser); + } + // NOTE: the check examRepository.isUserRegisteredForExam is not necessary because we already checked before that there is a student exam in this case for the current user + } + + /** + * Fetches an unfinished StudentExam for a test exam if one exists. If no unfinished StudentExam exists, generates a new one. + * + * @param exam The exam which StudentExam belongs to + * @param course The course which the exam belongs to + * @param currentUser The current user + * @return the StudentExam + * @throws BadRequestAlertException If the exam had already ended + * @throws IllegalStateException If the user has more than one unfinished student exam + */ + private StudentExam getOrGenerateTestExam(Exam exam, Course course, User currentUser) { StudentExam studentExam; - // If an studentExam can be fund, we can proceed + + if (this.examDateService.isExamOver(exam)) { + throw new BadRequestAlertException("Test exam has already ended", ENTITY_NAME, "examHasAlreadyEnded", true); + } + + List unfinishedStudentExams = studentExamRepository.findStudentExamsForTestExamsByUserIdAndExamId(currentUser.getId(), exam.getId()).stream() + .filter(attempt -> !attempt.isFinished()).toList(); + + if (unfinishedStudentExams.isEmpty()) { + studentExam = studentExamService.generateIndividualStudentExam(exam, currentUser); + // For the start of the exam, the exercises are not needed. They are later loaded via StudentExamResource + studentExam.setExercises(null); + } + else if (unfinishedStudentExams.size() == 1) { + studentExam = unfinishedStudentExams.getFirst(); + } + else { + throw new IllegalStateException( + "User " + currentUser.getId() + " has " + unfinishedStudentExams.size() + " unfinished test exams for exam " + exam.getId() + " in course " + course.getId()); + } + // Check that the current user is registered for the test exam. Otherwise, the student can self-register + examRegistrationService.checkRegistrationOrRegisterStudentToTestExam(course, exam.getId(), currentUser); + return studentExam; + } + + /** + * Fetches a real exam for the given examId and userId. + * + * @param examId the id of the Exam + * @param currentUser The current user + * @return the StudentExam + */ + private StudentExam getOrGenerateNormalExam(Long examId, User currentUser) { + // Check that the student exam exists + Optional optionalStudentExam = studentExamRepository.findByExamIdAndUserId(examId, currentUser.getId()); + // If an studentExam can be found, we can proceed if (optionalStudentExam.isPresent()) { - studentExam = optionalStudentExam.get(); + return optionalStudentExam.get(); } else { Exam examWithExerciseGroupsAndExercises = examRepository.findWithExerciseGroupsAndExercisesByIdOrElseThrow(examId); @@ -90,14 +158,15 @@ public StudentExam getExamInCourseElseThrow(Long courseId, Long examId) { boolean isExamEnded = ZonedDateTime.now().isAfter(examWithExerciseGroupsAndExercises.getEndDate()); // Generate a student exam if the following conditions are met: // 1. The exam has not ended. - // 2. The exam is either a test exam, OR it is a normal exam where the user is registered and can click the start button. + // 2. User is registered for the exam + // 3. User can click the start button. // Allowing student exams to be generated only when students can click the start button prevents inconsistencies. // For example, this avoids a scenario where a student generates an exam and an instructor adds an exercise group afterward. - if (!isExamEnded - && (examWithExerciseGroupsAndExercises.isTestExam() || (examRegistrationService.isUserRegisteredForExam(examId, currentUser.getId()) && canExamBeStarted))) { - studentExam = studentExamService.generateIndividualStudentExam(examWithExerciseGroupsAndExercises, currentUser); + if (!isExamEnded && examRegistrationService.isUserRegisteredForExam(examId, currentUser.getId()) && canExamBeStarted) { + StudentExam studentExam = studentExamService.generateIndividualStudentExam(examWithExerciseGroupsAndExercises, currentUser); // For the start of the exam, the exercises are not needed. They are later loaded via StudentExamResource studentExam.setExercises(null); + return studentExam; } else { @@ -105,27 +174,6 @@ public StudentExam getExamInCourseElseThrow(Long courseId, Long examId) { throw new BadRequestAlertException("Cannot generate student exam for exam ID " + examId + ".", ENTITY_NAME, "cannotGenerateStudentExam", true); } } - - Exam exam = studentExam.getExam(); - - checkExamBelongsToCourseElseThrow(courseId, exam); - - if (!examId.equals(exam.getId())) { - throw new BadRequestAlertException("The provided examId does not match with the examId of the studentExam", ENTITY_NAME, "examIdMismatch"); - } - - // Check that the exam is visible - if (exam.getVisibleDate() != null && exam.getVisibleDate().isAfter(ZonedDateTime.now())) { - throw new AccessForbiddenException(ENTITY_NAME, examId); - } - - if (exam.isTestExam()) { - // Check that the current user is registered for the test exam. Otherwise, the student can self-register - examRegistrationService.checkRegistrationOrRegisterStudentToTestExam(course, exam.getId(), currentUser); - } - // NOTE: the check examRepository.isUserRegisteredForExam is not necessary because we already checked before that there is a student exam in this case for the current user - - return studentExam; } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamDateService.java b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamDateService.java index dc95c653f6b0..4fa083116442 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamDateService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamDateService.java @@ -5,6 +5,7 @@ import java.time.ZonedDateTime; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -35,6 +36,16 @@ public ExamDateService(ExamRepository examRepository, StudentExamRepository stud this.studentExamRepository = studentExamRepository; } + /** + * Returns if the exam is over by checking if the exam end date has passed. + * + * @param exam the exam + * @return true if the exam is over + */ + public boolean isExamOver(Exam exam) { + return exam.getEndDate().isBefore(ZonedDateTime.now()); + } + /** * Returns if the exam is over by checking if the latest individual exam end date plus grace period has passed. * See {@link ExamDateService#getLatestIndividualExamEndDate} @@ -98,8 +109,11 @@ public boolean isIndividualExerciseWorkingPeriodOver(Exam exam, StudentParticipa if (studentParticipation.isTestRun()) { return false; } + // Students can participate in a test exam multiple times, meaning there can be multiple student exams for a single exam. + // For test exams, we aim to find the latest student exam. + // For real exams, we aim to find the only existing student exam. + Optional optionalStudentExam = studentExamRepository.findFirstByExamIdAndUserIdOrderByIdDesc(exam.getId(), studentParticipation.getParticipant().getId()); - var optionalStudentExam = studentExamRepository.findByExamIdAndUserId(exam.getId(), studentParticipation.getParticipant().getId()); if (optionalStudentExam.isPresent()) { StudentExam studentExam = optionalStudentExam.get(); return Boolean.TRUE.equals(studentExam.isSubmitted()) || studentExam.isEnded(); diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamService.java b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamService.java index 40184805ade5..39e5175df5f1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamService.java @@ -562,6 +562,7 @@ public void fetchParticipationsSubmissionsAndResultsForExam(StudentExam studentE public void filterParticipationForExercise(StudentExam studentExam, Exercise exercise, List participations, boolean isAtLeastInstructor) { // remove the unnecessary inner course attribute exercise.setCourse(null); + if (!(exercise instanceof QuizExercise)) { // Note: quiz exercises are filtered below exercise.filterSensitiveInformation(); diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamSubmissionService.java b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamSubmissionService.java index 7637b53787c6..4fab42bd38d4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamSubmissionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/service/ExamSubmissionService.java @@ -102,13 +102,17 @@ public boolean isAllowedToSubmitDuringExam(Exercise exercise, User user, boolean } private Optional findStudentExamForUser(User user, Exam exam) { - // Step 1: Find real exam - Optional optionalStudentExam = studentExamRepository.findWithExercisesByUserIdAndExamId(user.getId(), exam.getId(), false); - if (optionalStudentExam.isEmpty()) { - // Step 2: Find latest (=the highest id) unsubmitted test exam + + Optional optionalStudentExam; + // Since multiple student exams for a test exam might exist, find the latest (=the highest id) unsubmitted student exam + if (exam.isTestExam()) { optionalStudentExam = studentExamRepository.findUnsubmittedStudentExamsForTestExamsWithExercisesByExamIdAndUserId(exam.getId(), user.getId()).stream() .max(Comparator.comparing(StudentExam::getId)); } + else { + // for real exams, there's only one student exam per exam + optionalStudentExam = studentExamRepository.findWithExercisesByUserIdAndExamId(user.getId(), exam.getId(), false); + } return optionalStudentExam; } @@ -148,8 +152,8 @@ private boolean isExamTestRunSubmission(Exercise exercise, User user, Exam exam) * @return the submission. If a submission already exists for the exercise we will set the id */ public Submission preventMultipleSubmissions(Exercise exercise, Submission submission, User user) { - // Return immediately if it is not an exam submissions or if it is a programming exercise - if (!exercise.isExamExercise() || exercise instanceof ProgrammingExercise) { + // Return immediately if it is not an exam submission or if it is a programming exercise or if it is a test exam exercise + if (!exercise.isExamExercise() || exercise instanceof ProgrammingExercise || exercise.getExam().isTestExam()) { return submission; } diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/service/StudentExamService.java b/src/main/java/de/tum/cit/aet/artemis/exam/service/StudentExamService.java index a2898a9df5ff..4ccc92063049 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/service/StudentExamService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/service/StudentExamService.java @@ -30,7 +30,6 @@ import org.springframework.context.annotation.Profile; import org.springframework.scheduling.TaskScheduler; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import de.tum.cit.aet.artemis.communication.service.WebsocketMessagingService; import de.tum.cit.aet.artemis.core.domain.User; @@ -634,25 +633,21 @@ private void setUpTestRunExerciseParticipationsAndSubmissions(Long testRunId) { * Method to set up new participations for a StudentExam of a test exam. * * @param studentExam the studentExam for which the new participations should be set up - * @param startedDate the Date to which the InitializationDate should be set, in order to link StudentExam <-> participation */ - public void setUpTestExamExerciseParticipationsAndSubmissions(StudentExam studentExam, ZonedDateTime startedDate) { + public void setUpTestExamExerciseParticipationsAndSubmissions(StudentExam studentExam) { List generatedParticipations = Collections.synchronizedList(new ArrayList<>()); - setUpExerciseParticipationsAndSubmissionsWithInitializationDate(studentExam, generatedParticipations, startedDate); + setUpExerciseParticipationsAndSubmissions(studentExam, generatedParticipations); // TODO: Michael Allgaier: schedule a lock operation for all involved student repositories of this student exam (test exam) at the end of the individual working time studentParticipationRepository.saveAll(generatedParticipations); } /** - * Helper-Method for the Set up process of an StudentExam with a given startedDate. The method forces a new participation for every exercise, - * unlocks the Repository in case the StudentExam starts in less than 5mins and returns the generated participations + * Sets up the participations and submissions for all the exercises of the student exam. * - * @param studentExam the studentExam for which the new participations should be set up - * @param generatedParticipations the list where the newly generated participations should be added - * @param startedDate the Date to which the InitializationDate should be set, in order to link StudentExam <-> participation + * @param studentExam The studentExam for which the participations and submissions should be created + * @param generatedParticipations List of generated participations to track how many participations have been generated */ - private void setUpExerciseParticipationsAndSubmissionsWithInitializationDate(StudentExam studentExam, List generatedParticipations, - ZonedDateTime startedDate) { + public void setUpExerciseParticipationsAndSubmissions(StudentExam studentExam, List generatedParticipations) { User student = studentExam.getUser(); for (Exercise exercise : studentExam.getExercises()) { @@ -664,7 +659,7 @@ private void setUpExerciseParticipationsAndSubmissionsWithInitializationDate(Stu // TODO: directly check in the database if the entry exists for the student, exercise and InitializationState.INITIALIZED var studentParticipations = participationService.findByExerciseAndStudentId(exercise, student.getId()); // we start the exercise if no participation was found that was already fully initialized - if (studentParticipations.stream().noneMatch(studentParticipation -> studentParticipation.getParticipant().equals(student) + if (studentExam.isTestExam() || studentParticipations.stream().noneMatch(studentParticipation -> studentParticipation.getParticipant().equals(student) && studentParticipation.getInitializationState() != null && studentParticipation.getInitializationState().hasCompletedState(InitializationState.INITIALIZED))) { try { // Load lazy property @@ -673,14 +668,8 @@ private void setUpExerciseParticipationsAndSubmissionsWithInitializationDate(Stu programmingExercise.setTemplateParticipation(programmingExerciseReloaded.getTemplateParticipation()); } // this will also create initial (empty) submissions for quiz, text, modeling and file upload - // If the startedDate is provided, the InitializationDate is set to the startedDate - StudentParticipation participation; - if (startedDate != null) { - participation = participationService.startExerciseWithInitializationDate(exercise, student, true, startedDate); - } - else { - participation = participationService.startExercise(exercise, student, true); - } + StudentParticipation participation = participationService.startExercise(exercise, student, true); + generatedParticipations.add(participation); // Unlock repository and participation only if the real exam starts within 5 minutes or if we have a test exam or test run if (participation instanceof ProgrammingExerciseStudentParticipation programmingParticipation && exercise instanceof ProgrammingExercise programmingExercise) { @@ -702,6 +691,10 @@ private void setUpExerciseParticipationsAndSubmissionsWithInitializationDate(Stu } } } + if (!generatedParticipations.isEmpty()) { + studentExam.setStudentParticipations(generatedParticipations); + this.studentExamRepository.save(studentExam); + } } /** @@ -785,16 +778,6 @@ public Optional getExerciseStartStatusOfExam .map(wrapper -> (ExamExerciseStartPreparationStatus) wrapper.get()); } - /** - * Sets up the participations and submissions for all the exercises of the student exam. - * - * @param studentExam The studentExam for which the participations and submissions should be created - * @param generatedParticipations List of generated participations to track how many participations have been generated - */ - public void setUpExerciseParticipationsAndSubmissions(StudentExam studentExam, List generatedParticipations) { - setUpExerciseParticipationsAndSubmissionsWithInitializationDate(studentExam, generatedParticipations, null); - } - /** * Generates a new individual StudentExam for the specified student and stores it in the database. * @@ -823,7 +806,6 @@ public StudentExam generateIndividualStudentExam(Exam exam, User student) { * @param exam with eagerly loaded registered users, exerciseGroups and exercises loaded * @return the list of student exams with their corresponding users */ - @Transactional // TODO: NOT OK --> remove @Transactional public List generateStudentExams(final Exam exam) { final var existingStudentExams = studentExamRepository.findByExamId(exam.getId()); // deleteInBatch does not work, because it does not cascade the deletion of existing exam sessions, therefore use deleteAll @@ -844,7 +826,6 @@ public List generateStudentExams(final Exam exam) { * @param exam with eagerly loaded registered users, exerciseGroups and exercises loaded * @return the list of student exams with their corresponding users */ - @Transactional // TODO: NOT OK --> remove @Transactional public List generateMissingStudentExams(Exam exam) { // Get all users who already have an individual exam diff --git a/src/main/java/de/tum/cit/aet/artemis/exam/web/StudentExamResource.java b/src/main/java/de/tum/cit/aet/artemis/exam/web/StudentExamResource.java index 181b6b8205b1..c530e4b96151 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exam/web/StudentExamResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/exam/web/StudentExamResource.java @@ -6,7 +6,6 @@ import static java.time.ZonedDateTime.now; import java.time.ZonedDateTime; -import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Objects; import java.util.Set; @@ -179,7 +178,7 @@ public ResponseEntity getStudentExam(@PathVariable Long examAccessService.checkCourseAndExamAndStudentExamAccessElseThrow(courseId, examId, studentExamId); - StudentExam studentExam = studentExamRepository.findByIdWithExercisesSubmissionPolicyAndSessionsElseThrow(studentExamId); + StudentExam studentExam = studentExamRepository.findByIdWithExercisesAndSessionsAndStudentParticipationsElseThrow(studentExamId); examService.loadQuizExercisesForStudentExam(studentExam); @@ -463,7 +462,7 @@ public ResponseEntity> getStudentExamsForCoursePerUser(@PathVa User user = userRepository.getUserWithGroupsAndAuthorities(); studentExamAccessService.checkCourseAccessForStudentElseThrow(courseId, user); - List studentExamList = studentExamRepository.findStudentExamForTestExamsByUserIdAndCourseId(user.getId(), courseId); + List studentExamList = studentExamRepository.findStudentExamsForTestExamsByUserIdAndCourseId(user.getId(), courseId); return ResponseEntity.ok(studentExamList); } @@ -487,7 +486,7 @@ public ResponseEntity getStudentExamForSummary(@PathVariable Long c log.debug("REST request to get the student exam of user {} for exam {}", user.getLogin(), examId); // 1st: Get the studentExam from the database - StudentExam studentExam = studentExamRepository.findByIdWithExercisesElseThrow(studentExamId); + StudentExam studentExam = studentExamRepository.findByIdWithExercisesAndStudentParticipationsElseThrow(studentExamId); // 2nd: Check equal users and access permissions if (!user.equals(studentExam.getUser())) { @@ -562,8 +561,7 @@ public ResponseEntity> getExamLiveEvents(@PathVariabl User currentUser = userRepository.getUserWithGroupsAndAuthorities(); log.debug("REST request to get the exam live events for exam {} by user {}", examId, currentUser.getLogin()); - StudentExam studentExam = studentExamRepository.findByExamIdAndUserId(examId, currentUser.getId()) - .orElseThrow(() -> new EntityNotFoundException("StudentExam for exam " + examId + " and user " + currentUser.getId() + " does not exist")); + StudentExam studentExam = studentExamRepository.findOneByExamIdAndUserIdElseThrow(examId, currentUser.getId()); if (studentExam.isTestRun()) { throw new BadRequestAlertException("Test runs do not have live events", ENTITY_NAME, "testRunNoLiveEvents"); @@ -747,12 +745,9 @@ private void prepareStudentExamForConduction(HttpServletRequest request, User cu if (studentExam.isTestExam()) { boolean setupTestExamNeeded = studentExam.isStarted() == null || !studentExam.isStarted(); if (setupTestExamNeeded) { - // Fix startedDate. As the studentExam.startedDate is used to link the participation.initializationDate, we need to drop the ms - // (initializationDate is stored with ms) - ZonedDateTime startedDate = now().truncatedTo(ChronoUnit.SECONDS); - // Set up new participations for the Exercises and set initialisationDate to the startedDate - studentExamService.setUpTestExamExerciseParticipationsAndSubmissions(studentExam, startedDate); + // Set up new participations for the Exercises + studentExamService.setUpTestExamExerciseParticipationsAndSubmissions(studentExam); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Exercise.java b/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Exercise.java index 9fa635e491eb..d5ee754e33b7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Exercise.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Exercise.java @@ -375,6 +375,11 @@ public boolean isExamExercise() { return this.exerciseGroup != null; } + @JsonIgnore + public boolean isTestExamExercise() { + return isExamExercise() && this.getExam().isTestExam(); + } + /** * Utility method to get the course. Get the course over the exerciseGroup, if one was set, otherwise return * the course class member diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/domain/participation/Participation.java b/src/main/java/de/tum/cit/aet/artemis/exercise/domain/participation/Participation.java index ce891aeb9093..63d820ed2788 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/domain/participation/Participation.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/domain/participation/Participation.java @@ -119,6 +119,17 @@ public abstract class Participation extends DomainObject implements Participatio @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) private Set submissions = new HashSet<>(); + /** + * Graded course exercises, practice mode course exercises and real exam exercises always have only one parcitipation per exercise + * In case of a test exam, there are multiple participations possible for one exercise + * This field is necessary to preserve the constraint of one partipation per exercise, while allowing multiple particpiations per exercise for test exams + * The value is 0 for graded course exercises and exercises in the real exams + * The value is 1 for practice mode course exercises + * The value is 0-255 for test exam exercises. For each subsequent participation the number is increased by one + */ + @Column(name = "attempt") + private int attempt = 0; + /** * This property stores the total number of submissions in this participation. Not stored in the database, computed dynamically and used in showing statistics to the user in * the exercise view. @@ -245,6 +256,14 @@ public void setSubmissions(Set submissions) { this.submissions = submissions; } + public int getAttempt() { + return attempt; + } + + public void setAttempt(int index) { + this.attempt = index; + } + // jhipster-needle-entity-add-getters-setters - JHipster will add getters and setters here, do not remove /** @@ -356,4 +375,5 @@ public String toString() { @JsonIgnore public abstract String getType(); + } diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java index 499818ace8a2..7a85a31ff05c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/repository/StudentParticipationRepository.java @@ -118,6 +118,8 @@ SELECT COUNT(p.id) > 0 """) Optional findByExerciseIdAndStudentLogin(@Param("exerciseId") long exerciseId, @Param("username") String username); + Optional findFirstByExerciseIdAndStudentLoginOrderByIdDesc(long exerciseId, String username); + @Query(""" SELECT DISTINCT p FROM StudentParticipation p @@ -128,6 +130,21 @@ SELECT COUNT(p.id) > 0 """) Optional findWithEagerLegalSubmissionsByExerciseIdAndStudentLogin(@Param("exerciseId") long exerciseId, @Param("username") String username); + @Query(""" + SELECT DISTINCT p + FROM StudentParticipation p + LEFT JOIN FETCH p.submissions s + WHERE p.id = ( + SELECT MAX(p2.id) + FROM StudentParticipation p2 + LEFT JOIN p2.submissions s2 + WHERE p2.exercise.id = :exerciseId + AND p2.student.login = :username + AND (s2.type <> de.tum.cit.aet.artemis.exercise.domain.SubmissionType.ILLEGAL OR s2.type IS NULL) + ) + """) + Optional findLatestWithEagerLegalSubmissionsByExerciseIdAndStudentLogin(@Param("exerciseId") long exerciseId, @Param("username") String username); + @Query(""" SELECT DISTINCT p FROM StudentParticipation p @@ -417,7 +434,7 @@ default List findByExerciseIdWithManualResultAndFeedbacksA LEFT JOIN FETCH p.submissions WHERE p.exercise.id = :exerciseId AND p.student.id = :studentId - """) + """) List findByExerciseIdAndStudentIdWithEagerResultsAndSubmissions(@Param("exerciseId") long exerciseId, @Param("studentId") long studentId); @Query(""" @@ -753,6 +770,34 @@ List findByStudentIdAndIndividualExercisesWithEagerSubmiss List findByStudentIdAndIndividualExercisesWithEagerSubmissionsResultAndAssessorIgnoreTestRuns(@Param("studentId") long studentId, @Param("exercises") List exercises); + @Query(""" + SELECT DISTINCT p + FROM StudentExam se + JOIN se.exam e + JOIN se.studentParticipations p + LEFT JOIN FETCH p.submissions s + LEFT JOIN FETCH s.results r + LEFT JOIN FETCH r.assessor + WHERE p.testRun = FALSE + AND se.id IN :studentExamId + AND e.testExam = TRUE + """) + List findTestExamParticipationsByStudentIdAndIndividualExercisesWithEagerSubmissionsResultAndAssessorIgnoreTestRuns( + @Param("studentExamId") long studentExamId); + + @Query(""" + SELECT DISTINCT p + FROM StudentExam se + JOIN se.exam e + JOIN se.studentParticipations p + LEFT JOIN FETCH p.submissions s + LEFT JOIN FETCH s.results r + WHERE p.testRun = FALSE + AND se.id IN :studentExamId + AND e.testExam = TRUE + """) + List findTestExamParticipationsByStudentIdAndIndividualExercisesWithEagerSubmissionsResultIgnoreTestRuns(@Param("studentExamId") long studentExamId); + @Query(""" SELECT DISTINCT p FROM StudentParticipation p @@ -963,7 +1008,7 @@ private List filterParticipationsWithRelevantResults(List< /** * Get all participations for the given studentExam and exercises combined with their submissions with a result. - * Distinguishes between student exams and test runs and only loads the respective participations + * Distinguishes between real exams, test exams and test runs and only loads the respective participations * * @param studentExam studentExam with exercises loaded * @param withAssessor (only for non-test runs) if assessor should be loaded with the result @@ -973,6 +1018,15 @@ default List findByStudentExamWithEagerSubmissionsResult(S if (studentExam.isTestRun()) { return findTestRunParticipationsByStudentIdAndIndividualExercisesWithEagerSubmissionsResult(studentExam.getUser().getId(), studentExam.getExercises()); } + + if (studentExam.isTestExam()) { + if (withAssessor) { + return findTestExamParticipationsByStudentIdAndIndividualExercisesWithEagerSubmissionsResultAndAssessorIgnoreTestRuns(studentExam.getId()); + } + else { + return findTestExamParticipationsByStudentIdAndIndividualExercisesWithEagerSubmissionsResultIgnoreTestRuns(studentExam.getId()); + } + } else { if (withAssessor) { return findByStudentIdAndIndividualExercisesWithEagerSubmissionsResultAndAssessorIgnoreTestRuns(studentExam.getUser().getId(), studentExam.getExercises()); diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ParticipationService.java b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ParticipationService.java index 9074ad8ec1f8..34adc661155f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/service/ParticipationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/service/ParticipationService.java @@ -143,59 +143,67 @@ public ParticipationService(GitService gitService, Optional + * The method handles different scenarios based on whether the exercise is part of a test exam, course exercise, or a regular exam. + * In the case of a test exam, previous participations are marked as finished, and a new participation is created. For regular exercises, + * the method ensures that either a new participation is created or an existing one is reused. + *

+ * For programming exercises, additional steps like repository setup are handled by the `startProgrammingExercise` method. + * For other exercises (e.g., modeling, text, file-upload, or quiz), the participation is initialized accordingly, and, if required, + * an initial submission is created. * - * @param exercise the exercise which is started, a programming exercise needs to have the template and solution participation eagerly loaded - * @param participant the user or team who starts the exercise - * @param createInitialSubmission whether an initial empty submission should be created for text, modeling, quiz, file-upload or not - * @return the participation connecting the given exercise and user + * @param exercise the exercise that is being started. For programming exercises, template and solution participations should be eagerly loaded. + * @param participant the user or team starting the exercise + * @param createInitialSubmission whether an initial empty submission should be created for non-programming exercises such as text, modeling, quiz, or file-upload + * @return the `StudentParticipation` connecting the given exercise and participant */ public StudentParticipation startExercise(Exercise exercise, Participant participant, boolean createInitialSubmission) { - return startExerciseWithInitializationDate(exercise, participant, createInitialSubmission, null); - } - /** - * This method is called when an StudentExam for a test exam is set up for conduction. - * It creates a Participation which connects the corresponding student and exercise. The test exam is linked with the initializationDate = startedDate (StudentExam) - * Additionally, it configures repository / build plan related stuff for programming exercises. - * In the case of modeling or text exercises, it also initializes and stores the corresponding submission. - * - * @param exercise - the exercise for which a new participation is to be created - * @param participant - the user for which the new participation is to be created - * @param createInitialSubmission - whether an initial empty submission should be created for text, modeling, quiz, file-upload or not - * @param initializationDate - the date which should be set as the initializationDate of the Participation. Links studentExam <-> participation - * @return a new participation for the given exercise and user - */ - // TODO: Stephan Krusche: offer this method again like above "startExercise" without initializationDate which is not really necessary at the moment, because we only support on - // test exam per exam/student - public StudentParticipation startExerciseWithInitializationDate(Exercise exercise, Participant participant, boolean createInitialSubmission, ZonedDateTime initializationDate) { - // common for all exercises - Optional optionalStudentParticipation = findOneByExerciseAndParticipantAnyState(exercise, participant); - if (optionalStudentParticipation.isPresent() && optionalStudentParticipation.get().isPracticeMode() && exercise.isCourseExercise()) { - // In case there is already a practice participation, set it to inactive - optionalStudentParticipation.get().setInitializationState(InitializationState.INACTIVE); - studentParticipationRepository.saveAndFlush(optionalStudentParticipation.get()); + StudentParticipation participation; + Optional optionalStudentParticipation = Optional.empty(); - optionalStudentParticipation = findOneByExerciseAndParticipantAnyStateAndTestRun(exercise, participant, false); + // In case of a test exam we don't try to find an existing participation, because students can participate multiple times + // Instead, all previous participations are marked as finished and a new one is created + if (exercise.isTestExamExercise()) { + List participations = studentParticipationRepository.findByExerciseIdAndStudentId(exercise.getId(), participant.getId()); + participations.forEach(studentParticipation -> studentParticipation.setInitializationState(InitializationState.FINISHED)); + participation = createNewParticipation(exercise, participant); + participation.setAttempt(participations.size()); + participations.add(participation); + studentParticipationRepository.saveAll(participations); } - // Check if participation already exists - StudentParticipation participation; - if (optionalStudentParticipation.isEmpty()) { - participation = createNewParticipationWithInitializationDate(exercise, participant, initializationDate); - } + // All other cases, i.e. normal exercises, and regular exam exercises else { - // make sure participation and exercise are connected - participation = optionalStudentParticipation.get(); - participation.setExercise(exercise); + // common for all exercises + optionalStudentParticipation = findOneByExerciseAndParticipantAnyState(exercise, participant); + if (optionalStudentParticipation.isPresent() && optionalStudentParticipation.get().isPracticeMode() && exercise.isCourseExercise()) { + // In case there is already a practice participation, set it to inactive + optionalStudentParticipation.get().setInitializationState(InitializationState.INACTIVE); + studentParticipationRepository.saveAndFlush(optionalStudentParticipation.get()); + + optionalStudentParticipation = findOneByExerciseAndParticipantAnyStateAndTestRun(exercise, participant, false); + } + // Check if participation already exists + if (optionalStudentParticipation.isEmpty()) { + participation = createNewParticipation(exercise, participant); + } + else { + // make sure participation and exercise are connected + participation = optionalStudentParticipation.get(); + participation.setExercise(exercise); + } } if (exercise instanceof ProgrammingExercise programmingExercise) { // fetch again to get additional objects - participation = startProgrammingExercise(programmingExercise, (ProgrammingExerciseStudentParticipation) participation, initializationDate == null); + participation = startProgrammingExercise(programmingExercise, (ProgrammingExerciseStudentParticipation) participation); } - else {// for all other exercises: QuizExercise, ModelingExercise, TextExercise, FileUploadExercise + // for all other exercises: QuizExercise, ModelingExercise, TextExercise, FileUploadExercise + else { if (participation.getInitializationState() == null || participation.getInitializationState() == InitializationState.UNINITIALIZED || participation.getInitializationState() == InitializationState.FINISHED && !(exercise instanceof QuizExercise)) { // in case the participation was finished before, we set it to initialized again so that the user sees the correct button "Open modeling editor" on the client side. @@ -222,12 +230,11 @@ public StudentParticipation startExerciseWithInitializationDate(Exercise exercis /** * Helper Method to create a new Participation for the * - * @param exercise the exercise for which a participation should be created - * @param participant the participant for the participation - * @param initializationDate (optional) Value for the initializationDate of the Participation + * @param exercise the exercise for which a participation should be created + * @param participant the participant for the participation * @return a StudentParticipation for the exercise and participant with an optional specified initializationDate */ - private StudentParticipation createNewParticipationWithInitializationDate(Exercise exercise, Participant participant, ZonedDateTime initializationDate) { + private StudentParticipation createNewParticipation(Exercise exercise, Participant participant) { StudentParticipation participation; // create a new participation only if no participation can be found if (exercise instanceof ProgrammingExercise) { @@ -240,10 +247,6 @@ private StudentParticipation createNewParticipationWithInitializationDate(Exerci participation.setExercise(exercise); participation.setParticipant(participant); - // StartedDate is used to link a Participation to a test exam exercise - if (initializationDate != null) { - participation.setInitializationDate(initializationDate); - } participation = studentParticipationRepository.saveAndFlush(participation); if (exercise instanceof ProgrammingExercise && participant instanceof User user && profileService.isLocalVcsActive()) { @@ -257,16 +260,15 @@ private StudentParticipation createNewParticipationWithInitializationDate(Exerci * Start a programming exercise participation (which does not exist yet) by creating and configuring a student git repository (step 1) and a student build plan (step 2) * based on the templates in the given programming exercise * - * @param exercise the programming exercise that the currently active user (student) wants to start - * @param participation inactive participation - * @param setInitializationDate flag if the InitializationDate should be set to the current time + * @param exercise the programming exercise that the currently active user (student) wants to start + * @param participation inactive participation * @return started participation */ - private StudentParticipation startProgrammingExercise(ProgrammingExercise exercise, ProgrammingExerciseStudentParticipation participation, boolean setInitializationDate) { + private StudentParticipation startProgrammingExercise(ProgrammingExercise exercise, ProgrammingExerciseStudentParticipation participation) { // Step 1a) create the student repository (based on the template repository) participation = copyRepository(exercise, exercise.getVcsTemplateRepositoryUri(), participation); - return startProgrammingParticipation(exercise, participation, setInitializationDate); + return startProgrammingParticipation(exercise, participation); } /** @@ -290,10 +292,13 @@ private StudentParticipation startPracticeMode(ProgrammingExercise exercise, Pro participation = copyRepository(exercise, exercise.getVcsTemplateRepositoryUri(), participation); } - return startProgrammingParticipation(exercise, participation, true); + // For practice mode 1 is always set. For more information see Participation.class + participation.setAttempt(1); + + return startProgrammingParticipation(exercise, participation); } - private StudentParticipation startProgrammingParticipation(ProgrammingExercise exercise, ProgrammingExerciseStudentParticipation participation, boolean setInitializationDate) { + private StudentParticipation startProgrammingParticipation(ProgrammingExercise exercise, ProgrammingExerciseStudentParticipation participation) { // Step 1c) configure the student repository (e.g. access right, etc.) participation = configureRepository(exercise, participation); // Step 2a) create the build plan (based on the BASE build plan) @@ -304,11 +309,6 @@ private StudentParticipation startProgrammingParticipation(ProgrammingExercise e configureRepositoryWebHook(participation); // Step 4a) Set the InitializationState to initialized to indicate, the programming exercise is ready participation.setInitializationState(InitializationState.INITIALIZED); - // Step 4b) Set the InitializationDate to the current time - if (setInitializationDate) { - // Note: For test exams, the InitializationDate is set to the StudentExam: startedDate in {#link #startExerciseWithInitializationDate} - participation.setInitializationDate(ZonedDateTime.now()); - } // after saving, we need to make sure the object that is used after the if statement is the right one return participation; } @@ -477,7 +477,7 @@ private ProgrammingExerciseStudentParticipation copyRepository(ProgrammingExerci VersionControlService vcs = versionControlService.orElseThrow(); String templateBranch = vcs.getOrRetrieveBranchOfExercise(programmingExercise); // the next action includes recovery, which means if the repository has already been copied, we simply retrieve the repository uri and do not copy it again - var newRepoUri = vcs.copyRepository(projectKey, templateRepoName, templateBranch, projectKey, repoName); + var newRepoUri = vcs.copyRepository(projectKey, templateRepoName, templateBranch, projectKey, repoName, participation.getAttempt()); // add the userInfo part to the repoUri only if the participation belongs to a single student (and not a team of students) if (participation.getStudent().isPresent()) { newRepoUri = newRepoUri.withUser(participation.getParticipantIdentifier()); @@ -593,6 +593,11 @@ public Optional findOneByExerciseAndStudentLoginAnyState(E Optional optionalTeam = teamRepository.findOneByExerciseIdAndUserLogin(exercise.getId(), username); return optionalTeam.flatMap(team -> studentParticipationRepository.findOneByExerciseIdAndTeamId(exercise.getId(), team.getId())); } + + if (exercise.isTestExamExercise()) { + return studentParticipationRepository.findFirstByExerciseIdAndStudentLoginOrderByIdDesc(exercise.getId(), username); + } + return studentParticipationRepository.findByExerciseIdAndStudentLogin(exercise.getId(), username); } @@ -667,6 +672,10 @@ public Optional findOneByExerciseAndStudentLoginWithEagerS Optional optionalTeam = teamRepository.findOneByExerciseIdAndUserLogin(exercise.getId(), username); return optionalTeam.flatMap(team -> studentParticipationRepository.findWithEagerLegalSubmissionsAndTeamStudentsByExerciseIdAndTeamId(exercise.getId(), team.getId())); } + // If exercise is a test exam exercise we load the last participation, since there are multiple participations + if (exercise.isTestExamExercise()) { + return studentParticipationRepository.findLatestWithEagerLegalSubmissionsByExerciseIdAndStudentLogin(exercise.getId(), username); + } return studentParticipationRepository.findWithEagerLegalSubmissionsByExerciseIdAndStudentLogin(exercise.getId(), username); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java index cc1f57c533fa..8f08496d1423 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/ProgrammingExerciseStudentParticipationRepository.java @@ -76,6 +76,8 @@ Optional findByIdWithAllResultsAndRelat Optional findByExerciseIdAndStudentLogin(long exerciseId, String username); + Optional findFirstByExerciseIdAndStudentLoginOrderByIdDesc(long exerciseId, String username); + List findAllByExerciseIdAndStudentLogin(long exerciseId, String username); default ProgrammingExerciseStudentParticipation findByExerciseIdAndStudentLoginOrThrow(long exerciseId, String username) { @@ -88,15 +90,36 @@ default ProgrammingExerciseStudentParticipation findByRepositoryUriElseThrow(Str return getValueElseThrow(findByRepositoryUri(repositoryUri)); } + default ProgrammingExerciseStudentParticipation findFirstByExerciseIdAndStudentLoginOrThrow(long exerciseId, String username) { + return getValueElseThrow(findFirstByExerciseIdAndStudentLoginOrderByIdDesc(exerciseId, username)); + } + @EntityGraph(type = LOAD, attributePaths = { "submissions" }) Optional findWithSubmissionsByExerciseIdAndStudentLogin(long exerciseId, String username); + @Query(""" + SELECT participation + FROM ProgrammingExerciseStudentParticipation participation + LEFT JOIN FETCH participation.submissions s + WHERE participation.exercise.id = :exerciseId + AND participation.student.login = :username + ORDER BY participation.id DESC + """) + List findFirstWithSubmissionsByExerciseIdAndStudentLoginOrderByIdDesc(@Param("exerciseId") long exerciseId, + @Param("username") String username); + default ProgrammingExerciseStudentParticipation findWithSubmissionsByExerciseIdAndStudentLoginOrThrow(long exerciseId, String username) { return getValueElseThrow(findWithSubmissionsByExerciseIdAndStudentLogin(exerciseId, username)); } + default ProgrammingExerciseStudentParticipation findFirstWithSubmissionsByExerciseIdAndStudentLoginOrThrow(long exerciseId, String username) { + return getValueElseThrow(findFirstWithSubmissionsByExerciseIdAndStudentLoginOrderByIdDesc(exerciseId, username).stream().findFirst()); + } + Optional findByExerciseIdAndStudentLoginAndTestRun(long exerciseId, String username, boolean testRun); + Optional findFirstByExerciseIdAndStudentLoginAndTestRunOrderByIdDesc(long exerciseId, String username, boolean testRun); + @EntityGraph(type = LOAD, attributePaths = { "team.students" }) Optional findByExerciseIdAndTeamId(long exerciseId, long teamId); @@ -185,6 +208,22 @@ JOIN TREAT (participation.exercise AS ProgrammingExercise) pe Page findRepositoryUrisByRecentDueDateOrRecentExamEndDate(@Param("earliestDate") ZonedDateTime earliestDate, @Param("latestDate") ZonedDateTime latestDate, Pageable pageable); + @Query(""" + SELECT participation + FROM ProgrammingExerciseStudentParticipation participation + LEFT JOIN FETCH participation.submissions s + WHERE participation.exercise.id = :exerciseId + AND participation.student.login = :username + AND participation.testRun = :testRun + ORDER BY participation.id DESC + """) + List findFirstWithSubmissionsByExerciseIdAndStudentLoginAndTestRunOrderByIdDesc(@Param("exerciseId") long exerciseId, + @Param("username") String username, @Param("testRun") boolean testRun); + + default Optional findFirstWithSubmissionsByExerciseIdAndStudentLoginAndTestRun(long exerciseId, String username, boolean testRun) { + return findFirstWithSubmissionsByExerciseIdAndStudentLoginAndTestRunOrderByIdDesc(exerciseId, username, testRun).stream().findFirst(); + } + @Query(""" SELECT participation FROM ProgrammingExerciseStudentParticipation participation diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportService.java index 710837ac4fb4..823c50dbe670 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseImportService.java @@ -112,14 +112,14 @@ public void importRepositories(final ProgrammingExercise templateExercise, final String sourceBranch = versionControl.getOrRetrieveBranchOfExercise(templateExercise); // TODO: in case one of those operations fail, we should do error handling and revert all previous operations - versionControl.copyRepository(sourceProjectKey, templateRepoName, sourceBranch, targetProjectKey, RepositoryType.TEMPLATE.getName()); - versionControl.copyRepository(sourceProjectKey, solutionRepoName, sourceBranch, targetProjectKey, RepositoryType.SOLUTION.getName()); - versionControl.copyRepository(sourceProjectKey, testRepoName, sourceBranch, targetProjectKey, RepositoryType.TESTS.getName()); + versionControl.copyRepository(sourceProjectKey, templateRepoName, sourceBranch, targetProjectKey, RepositoryType.TEMPLATE.getName(), null); + versionControl.copyRepository(sourceProjectKey, solutionRepoName, sourceBranch, targetProjectKey, RepositoryType.SOLUTION.getName(), null); + versionControl.copyRepository(sourceProjectKey, testRepoName, sourceBranch, targetProjectKey, RepositoryType.TESTS.getName(), null); List auxRepos = templateExercise.getAuxiliaryRepositories(); for (int i = 0; i < auxRepos.size(); i++) { AuxiliaryRepository auxRepo = auxRepos.get(i); - var repoUri = versionControl.copyRepository(sourceProjectKey, auxRepo.getRepositoryName(), sourceBranch, targetProjectKey, auxRepo.getName()).toString(); + var repoUri = versionControl.copyRepository(sourceProjectKey, auxRepo.getRepositoryName(), sourceBranch, targetProjectKey, auxRepo.getName(), null).toString(); AuxiliaryRepository newAuxRepo = newExercise.getAuxiliaryRepositories().get(i); newAuxRepo.setRepositoryUri(repoUri); auxiliaryRepositoryRepository.save(newAuxRepo); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java index bd8db678b5a2..6d3fe82ef3fe 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java @@ -154,6 +154,27 @@ public ProgrammingExerciseStudentParticipation findTeamParticipationByExerciseAn return participationOptional.get(); } + /** + * Tries to retrieve a programming exercise participation for the given programming exercise, username + * + * @param exercise the programming exercise for which to find a participation. + * @param username of the user to which the participation belongs. + * @param withSubmissions true if the participation should be loaded with its submissions. + * @return the participation for the given programming exercise and user. + * @throws EntityNotFoundException if there is no participation for the given exercise and user. + */ + @NotNull + public ProgrammingExerciseStudentParticipation findStudentParticipationByExerciseAndStudentLoginOrThrow(ProgrammingExercise exercise, String username, + boolean withSubmissions) { + + if (withSubmissions) { + return studentParticipationRepository.findFirstWithSubmissionsByExerciseIdAndStudentLoginOrThrow(exercise.getId(), username); + } + else { + return studentParticipationRepository.findFirstByExerciseIdAndStudentLoginOrThrow(exercise.getId(), username); + } + } + /** * Tries to retrieve a student participation for the given team exercise and user * @@ -183,10 +204,10 @@ public ProgrammingExerciseStudentParticipation findStudentParticipationByExercis Optional participationOptional; if (withSubmissions) { - participationOptional = studentParticipationRepository.findWithSubmissionsByExerciseIdAndStudentLoginAndTestRun(exercise.getId(), username, isTestRun); + participationOptional = studentParticipationRepository.findFirstWithSubmissionsByExerciseIdAndStudentLoginAndTestRun(exercise.getId(), username, isTestRun); } else { - participationOptional = studentParticipationRepository.findByExerciseIdAndStudentLoginAndTestRun(exercise.getId(), username, isTestRun); + participationOptional = studentParticipationRepository.findFirstByExerciseIdAndStudentLoginAndTestRunOrderByIdDesc(exercise.getId(), username, isTestRun); } if (participationOptional.isEmpty()) { @@ -492,11 +513,7 @@ public ProgrammingExerciseParticipation retrieveParticipationForRepository(Progr boolean isExamEditorRepository = exercise.isExamExercise() && authorizationCheckService.isAtLeastEditorForExercise(exercise, userRepository.getUserByLoginElseThrow(repositoryTypeOrUserName)); if (isExamEditorRepository) { - if (withSubmissions) { - return studentParticipationRepository.findWithSubmissionsByExerciseIdAndStudentLoginOrThrow(exercise.getId(), repositoryTypeOrUserName); - } - - return studentParticipationRepository.findByExerciseIdAndStudentLoginOrThrow(exercise.getId(), repositoryTypeOrUserName); + return findStudentParticipationByExerciseAndStudentLoginOrThrow(exercise, repositoryTypeOrUserName, withSubmissions); } return findStudentParticipationByExerciseAndStudentLoginAndTestRunOrThrow(exercise, repositoryTypeOrUserName, isPracticeRepository, withSubmissions); diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCRepositoryUri.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCRepositoryUri.java index 796c0df5fc02..ce96e0af53df 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCRepositoryUri.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/LocalVCRepositoryUri.java @@ -208,7 +208,8 @@ private String buildRepositoryPath(String projectKey, String repositorySlug) { * @return The normalized repository type or username, free of the project key prefix and "practice-" designation. */ private String getRepositoryTypeOrUserName(String repositorySlug, String projectKey) { - String repositoryTypeOrUserNameWithPracticePrefix = repositorySlug.toLowerCase().replace(projectKey.toLowerCase() + "-", ""); + String pattern = projectKey.toLowerCase() + "\\d*-"; + String repositoryTypeOrUserNameWithPracticePrefix = repositorySlug.toLowerCase().replaceAll(pattern, ""); return repositoryTypeOrUserNameWithPracticePrefix.replace("practice-", ""); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/vcs/AbstractVersionControlService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/vcs/AbstractVersionControlService.java index 454814afc442..cde295f0ebb1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/vcs/AbstractVersionControlService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/vcs/AbstractVersionControlService.java @@ -99,11 +99,15 @@ public void addWebHookForParticipation(ProgrammingExerciseParticipation particip } @Override - public VcsRepositoryUri copyRepository(String sourceProjectKey, String sourceRepositoryName, String sourceBranch, String targetProjectKey, String targetRepositoryName) - throws VersionControlException { + public VcsRepositoryUri copyRepository(String sourceProjectKey, String sourceRepositoryName, String sourceBranch, String targetProjectKey, String targetRepositoryName, + Integer numberOfAttempts) throws VersionControlException { sourceRepositoryName = sourceRepositoryName.toLowerCase(); targetRepositoryName = targetRepositoryName.toLowerCase(); - final String targetRepoSlug = targetProjectKey.toLowerCase() + "-" + targetRepositoryName; + String targetProjectKeyLowerCase = targetProjectKey.toLowerCase(); + if (numberOfAttempts != null && numberOfAttempts > 0 && !targetRepositoryName.contains("practice-")) { + targetProjectKeyLowerCase = targetProjectKeyLowerCase + numberOfAttempts; + } + final String targetRepoSlug = targetProjectKeyLowerCase + "-" + targetRepositoryName; // get the remote url of the source repo final var sourceRepoUri = getCloneRepositoryUri(sourceProjectKey, sourceRepositoryName); // get the remote url of the target repo diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/vcs/VersionControlService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/vcs/VersionControlService.java index f467a090dec9..0c70895177d6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/vcs/VersionControlService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/vcs/VersionControlService.java @@ -125,11 +125,12 @@ public interface VersionControlService { * @param sourceBranch The default branch of the source repository * @param targetProjectKey The key of the target project to which to copy the new repository to * @param targetRepositoryName The desired name of the target repository + * @param numberOfAttempts The attempt number * @return The URL for cloning the repository * @throws VersionControlException if the repository could not be copied on the VCS server (e.g. because the source repo does not exist) */ - VcsRepositoryUri copyRepository(String sourceProjectKey, String sourceRepositoryName, String sourceBranch, String targetProjectKey, String targetRepositoryName) - throws VersionControlException; + VcsRepositoryUri copyRepository(String sourceProjectKey, String sourceRepositoryName, String sourceBranch, String targetProjectKey, String targetRepositoryName, + Integer numberOfAttempts) throws VersionControlException; /** * Add the user to the repository diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java index be1c99c67be6..64bb710c2a37 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java @@ -493,7 +493,7 @@ private void hasAccessToParticipationElseThrow(ProgrammingExerciseStudentPartici * @return true if the results should be hidden, false otherwise */ private boolean shouldHideExamExerciseResults(ProgrammingExerciseStudentParticipation participation) { - if (participation.getProgrammingExercise().isExamExercise()) { + if (participation.getProgrammingExercise().isExamExercise() && !participation.getProgrammingExercise().isTestExamExercise()) { User student = participation.getStudent() .orElseThrow(() -> new EntityNotFoundException("Participation with id " + participation.getId() + " does not have a student!")); var studentExam = studentExamRepository.findByExerciseIdAndUserId(participation.getExercise().getId(), student.getId()) diff --git a/src/main/resources/config/liquibase/changelog/20240513101552_changelog.xml b/src/main/resources/config/liquibase/changelog/20240513101552_changelog.xml new file mode 100644 index 000000000000..25159c5c8242 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20240513101552_changelog.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/config/liquibase/changelog/20240513101555_changelog.xml b/src/main/resources/config/liquibase/changelog/20240513101555_changelog.xml new file mode 100644 index 000000000000..dc91b82e1eb7 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20240513101555_changelog.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/config/liquibase/changelog/20240513101557_changelog.xml b/src/main/resources/config/liquibase/changelog/20240513101557_changelog.xml new file mode 100644 index 000000000000..bc46d874bfd3 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20240513101557_changelog.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ 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 5ae5903ea896..15a75e992523 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -9,6 +9,9 @@ + + + diff --git a/src/main/webapp/app/exam/participate/exam-participation.component.ts b/src/main/webapp/app/exam/participate/exam-participation.component.ts index 7871ce88d681..67568e7acbb7 100644 --- a/src/main/webapp/app/exam/participate/exam-participation.component.ts +++ b/src/main/webapp/app/exam/participate/exam-participation.component.ts @@ -201,6 +201,15 @@ export class ExamParticipationComponent implements OnInit, OnDestroy, ComponentC }, error: () => (this.loadingExam = false), }); + } else if (this.testExam && this.studentExamId) { + this.examParticipationService.loadStudentExamWithExercisesForSummary(this.courseId, this.examId, this.studentExamId).subscribe({ + next: (studentExam) => { + this.handleStudentExam(studentExam); + }, + error: () => { + this.handleNoStudentExam(); + }, + }); } else { this.examParticipationService.getOwnStudentExam(this.courseId, this.examId).subscribe({ next: (studentExam) => { @@ -374,10 +383,6 @@ export class ExamParticipationComponent implements OnInit, OnDestroy, ComponentC ) .subscribe({ next: () => { - if (this.testExam) { - // If we have a test exam, we reload the summary from the server right away - this.loadAndDisplaySummary(); - } this.submitInProgress = false; // As we don't get the student exam from the server, we need to set the submitted flag and the submission date manually @@ -394,6 +399,7 @@ export class ExamParticipationComponent implements OnInit, OnDestroy, ComponentC if (this.testExam) { this.examParticipationService.resetExamLayout(); + this.router.navigate(['courses', this.courseId, 'exams', this.examId, 'test-exam', this.studentExam.id]); } this.examSummaryButtonTimer = setInterval(() => { diff --git a/src/main/webapp/app/exam/participate/exam-participation.route.ts b/src/main/webapp/app/exam/participate/exam-participation.route.ts index a104b0ad964e..38ee3081db5e 100644 --- a/src/main/webapp/app/exam/participate/exam-participation.route.ts +++ b/src/main/webapp/app/exam/participate/exam-participation.route.ts @@ -1,7 +1,6 @@ import { Routes } from '@angular/router'; import { UserRouteAccessService } from 'app/core/auth/user-route-access-service'; import { ExamParticipationComponent } from 'app/exam/participate/exam-participation.component'; -import { PendingChangesGuard } from 'app/shared/guard/pending-changes.guard'; import { Authority } from 'app/shared/constants/authority.constants'; import { GradingKeyOverviewComponent } from 'app/grading-system/grading-key-overview/grading-key-overview.component'; import { ExampleSolutionComponent } from 'app/exercises/shared/example-solution/example-solution.component'; @@ -35,16 +34,6 @@ export const examParticipationRoute: Routes = [ }, canActivate: [UserRouteAccessService], }, - { - path: 'test-exam/:studentExamId', - component: ExamParticipationComponent, - data: { - authorities: [Authority.USER], - pageTitle: 'artemisApp.exam.title', - }, - canActivate: [UserRouteAccessService], - canDeactivate: [PendingChangesGuard], - }, { path: 'exercises/:exerciseId/example-solution', component: ExampleSolutionComponent, diff --git a/src/main/webapp/app/exam/participate/exam-start-information/exam-start-information.component.ts b/src/main/webapp/app/exam/participate/exam-start-information/exam-start-information.component.ts index 33237b989106..e563c8cb9995 100644 --- a/src/main/webapp/app/exam/participate/exam-start-information/exam-start-information.component.ts +++ b/src/main/webapp/app/exam/participate/exam-start-information/exam-start-information.component.ts @@ -29,6 +29,7 @@ export class ExamStartInformationComponent implements OnInit { numberOfExercisesInExam?: number; examinedStudent?: string; startDate?: dayjs.Dayjs; + endDate?: dayjs.Dayjs; gracePeriodInMinutes?: number; ngOnInit(): void { @@ -40,6 +41,7 @@ export class ExamStartInformationComponent implements OnInit { this.numberOfExercisesInExam = this.exam.numberOfExercisesInExam; this.examinedStudent = this.studentExam.user?.name; this.startDate = this.exam.startDate; + this.endDate = this.exam.endDate; this.gracePeriodInMinutes = Math.floor(this.exam.gracePeriod! / 60); this.prepareInformationBoxData(); @@ -71,9 +73,23 @@ export class ExamStartInformationComponent implements OnInit { const informationBoxExaminedStudent = this.buildInformationBox('artemisApp.exam.examinedStudent', this.examinedStudent!); this.examInformationBoxData.push(informationBoxExaminedStudent); } - if (this.startDate) { - const informationBoxStartDate = this.buildInformationBox('artemisApp.exam.date', this.startDate.toString(), 'formatedDate'); - this.examInformationBoxData.push(informationBoxStartDate); + + // For test exams, display both the start and end dates, as the working time applies to a single attempt. + if (this.exam.testExam) { + if (this.startDate) { + const informationBoxStartDate = this.buildInformationBox('artemisApp.exam.startDate', this.startDate.toString(), 'formatedDate'); + this.examInformationBoxData.push(informationBoxStartDate); + } + + if (this.endDate) { + const informationBoxStartDate = this.buildInformationBox('artemisApp.exam.endDate', this.endDate.toString(), 'formatedDate'); + this.examInformationBoxData.push(informationBoxStartDate); + } + } else { + if (this.startDate) { + const informationBoxStartDate = this.buildInformationBox('artemisApp.exam.date', this.startDate.toString(), 'formatedDate'); + this.examInformationBoxData.push(informationBoxStartDate); + } } const informationBoxTotalWorkingTime = this.buildInformationBox('artemisApp.exam.workingTime', this.exam.workingTime!, 'workingTime'); diff --git a/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html b/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html index 2a0df39f1353..3791ad8c6b77 100644 --- a/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html +++ b/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html @@ -9,7 +9,7 @@


diff --git a/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.ts b/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.ts index 5ffdffc38018..95cb593f6584 100644 --- a/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.ts +++ b/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.ts @@ -97,4 +97,16 @@ export class ProgrammingExamSummaryComponent implements OnInit { this.localVCEnabled = profileInfo.activeProfiles?.includes(PROFILE_LOCALVC); }); } + + get routerLinkForRepositoryView(): string { + if (this.routerLink.includes('test-exam')) { + // For test exams, the routerLink follows the format: /courses/{courseId}/exams/{examId}/test-exam/{studentExam} + // We need to remove /test-exam/{studentExam} to construct the correct repository link. + const parts = this.routerLink.split('/'); + const examLink = parts.slice(0, parts.length - 2).join('/'); + return `${examLink}/exercises/${this.exercise.id}/repository/${this.participation.id}`; + } else { + return `${this.routerLink}/exercises/${this.exercise.id}/repository/${this.participation.id}`; + } + } } diff --git a/src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.ts b/src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.ts index d1e6fcf1bbe3..f4c2f7dca324 100644 --- a/src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.ts +++ b/src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.ts @@ -14,7 +14,7 @@ export class StudentExamWorkingTimeComponent implements OnInit { isTestRun = false; ngOnInit() { - if (this.studentExam.exam && this.studentExam.workingTime) { + if (this.studentExam.exam && this.studentExam.workingTime && !this.studentExam.exam.testExam) { this.percentDifference = getRelativeWorkingTimeExtension(this.studentExam.exam, this.studentExam.workingTime); } this.isTestRun = this.studentExam.testRun ?? false; diff --git a/src/main/webapp/app/overview/course-exams/course-exams.component.ts b/src/main/webapp/app/overview/course-exams/course-exams.component.ts index 33e28167ea69..584bd866e0b9 100644 --- a/src/main/webapp/app/overview/course-exams/course-exams.component.ts +++ b/src/main/webapp/app/overview/course-exams/course-exams.component.ts @@ -1,7 +1,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { Course } from 'app/entities/course.model'; -import { ActivatedRoute, Router } from '@angular/router'; -import { Subscription } from 'rxjs'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { Subscription, interval, lastValueFrom } from 'rxjs'; import { Exam } from 'app/entities/exam/exam.model'; import dayjs from 'dayjs/esm'; import { ArtemisServerDateService } from 'app/shared/server-date.service'; @@ -12,16 +12,17 @@ import { CourseStorageService } from 'app/course/manage/course-storage.service'; import { AccordionGroups, CollapseState, SidebarCardElement, SidebarData } from 'app/types/sidebar'; import { CourseOverviewService } from '../course-overview.service'; import { cloneDeep } from 'lodash-es'; -import { lastValueFrom } from 'rxjs'; const DEFAULT_UNIT_GROUPS: AccordionGroups = { real: { entityData: [] }, test: { entityData: [] }, + attempt: { entityData: [] }, }; const DEFAULT_COLLAPSE_STATE: CollapseState = { real: false, test: false, + attempt: false, }; const DEFAULT_SHOW_ALWAYS: CollapseState = { @@ -46,6 +47,7 @@ export class CourseExamsComponent implements OnInit, OnDestroy { public expandAttemptsMap = new Map(); public realExamsOfCourse: Exam[] = []; public testExamsOfCourse: Exam[] = []; + studentExamState: Subscription; // Icons faAngleUp = faAngleUp; @@ -54,12 +56,14 @@ export class CourseExamsComponent implements OnInit, OnDestroy { sortedRealExams?: Exam[]; sortedTestExams?: Exam[]; + testExamMap: Map = new Map(); examSelected = true; accordionExamGroups: AccordionGroups = DEFAULT_UNIT_GROUPS; sidebarData: SidebarData; sidebarExams: SidebarCardElement[] = []; isCollapsed = false; isExamStarted = false; + withinWorkingTime: boolean; readonly DEFAULT_COLLAPSE_STATE = DEFAULT_COLLAPSE_STATE; protected readonly DEFAULT_SHOW_ALWAYS = DEFAULT_SHOW_ALWAYS; @@ -99,8 +103,18 @@ export class CourseExamsComponent implements OnInit, OnDestroy { .loadStudentExamsForTestExamsPerCourseAndPerUserForOverviewPage(this.courseId) .subscribe((response: StudentExam[]) => { this.studentExams = response!; + this.prepareSidebarData(); }); + this.router.events.subscribe((event) => { + if (event instanceof NavigationEnd) { + this.examParticipationService.loadStudentExamsForTestExamsPerCourseAndPerUserForOverviewPage(this.courseId).subscribe((response: StudentExam[]) => { + this.studentExams = response!; + this.prepareSidebarData(); + }); + } + }); + if (this.course?.exams) { // The Map is ued to store the boolean value, if the attempt-List for one Exam has been expanded or collapsed this.expandAttemptsMap = new Map(this.course.exams.filter((exam) => exam.testExam && this.isVisible(exam)).map((exam) => [exam.id!, false])); @@ -119,7 +133,11 @@ export class CourseExamsComponent implements OnInit, OnDestroy { if (!examId && lastSelectedExam) { this.router.navigate([lastSelectedExam], { relativeTo: this.route, replaceUrl: true }); } else if (!examId && upcomingExam) { - this.router.navigate([upcomingExam.id], { relativeTo: this.route, replaceUrl: true }); + if (upcomingExam.testExam) { + this.router.navigate([upcomingExam.id + '/test-exam' + '/start'], { relativeTo: this.route, replaceUrl: true }); + } else { + this.router.navigate([upcomingExam.id], { relativeTo: this.route, replaceUrl: true }); + } } else { this.examSelected = examId ? true : false; } @@ -159,6 +177,7 @@ export class CourseExamsComponent implements OnInit, OnDestroy { } this.studentExamTestExamUpdateSubscription?.unsubscribe(); this.examStartedSubscription?.unsubscribe(); + this.unsubscribeFromExamStateSubscription(); } /** @@ -217,16 +236,31 @@ export class CourseExamsComponent implements OnInit, OnDestroy { const examCardItem = this.courseOverviewService.mapExamToSidebarCardElement(realExam, this.studentExamsForRealExams.get(realExam.id!)); groupedExamGroups['real'].entityData.push(examCardItem); } - for (const testExam of testExams) { - const examCardItem = this.courseOverviewService.mapExamToSidebarCardElement(testExam); + testExams.forEach((testExam) => { + const examCardItem = this.courseOverviewService.mapExamToSidebarCardElement( + testExam, + this.studentExamsForRealExams.get(testExam.id!), + this.getNumberOfAttemptsForTestExam(testExam), + ); groupedExamGroups['test'].entityData.push(examCardItem); - } - + const testExamAttempts = this.testExamMap.get(testExam.id!); + if (testExamAttempts) { + testExamAttempts.forEach((attempt, index) => { + const attemptNumber = testExamAttempts.length - index; + const attemptCardItem = this.courseOverviewService.mapAttemptToSidebarCardElement(attempt, attemptNumber); + groupedExamGroups['attempt'].entityData.push(attemptCardItem); + }); + } + }); return groupedExamGroups; } getLastSelectedExam(): string | null { - return sessionStorage.getItem('sidebar.lastSelectedItem.exam.byCourse.' + this.courseId); + let lastSelectedExam = sessionStorage.getItem('sidebar.lastSelectedItem.exam.byCourse.' + this.courseId); + if (lastSelectedExam && lastSelectedExam.startsWith('"') && lastSelectedExam.endsWith('"')) { + lastSelectedExam = lastSelectedExam.slice(1, -1); + } + return lastSelectedExam; } toggleSidebar() { @@ -251,11 +285,24 @@ export class CourseExamsComponent implements OnInit, OnDestroy { this.sortedRealExams = this.realExamsOfCourse.sort((a, b) => this.sortExamsByStartDate(a, b)); this.sortedTestExams = this.testExamsOfCourse.sort((a, b) => this.sortExamsByStartDate(a, b)); + for (const testExam of this.sortedTestExams) { + const orderedTestExamAttempts = this.getStudentExamForExamIdOrderedByIdReverse(testExam.id!); + orderedTestExamAttempts.forEach((attempt, index) => { + this.calculateIndividualWorkingTimeForTestExams(attempt, index === 0); + }); + const submittedAttempts = orderedTestExamAttempts.filter((attempt) => attempt.submitted); + this.testExamMap.set(testExam.id!, submittedAttempts); + } const sidebarRealExams = this.courseOverviewService.mapExamsToSidebarCardElements(this.sortedRealExams, this.getAllStudentExamsForRealExams()); const sidebarTestExams = this.courseOverviewService.mapExamsToSidebarCardElements(this.sortedTestExams); + const allStudentExams = this.getAllStudentExams(); + const sidebarTestExamAttempts = this.courseOverviewService.mapTestExamAttemptsToSidebarCardElements( + allStudentExams, + this.getIndicesForStudentExams(allStudentExams.length), + ); - this.sidebarExams = [...sidebarRealExams, ...sidebarTestExams]; + this.sidebarExams = [...sidebarRealExams, ...sidebarTestExams, ...(sidebarTestExamAttempts ?? [])]; this.accordionExamGroups = this.groupExamsByRealOrTest(this.sortedRealExams, this.sortedTestExams); this.updateSidebarData(); @@ -271,4 +318,68 @@ export class CourseExamsComponent implements OnInit, OnDestroy { getAllStudentExamsForRealExams(): StudentExam[] { return [...this.studentExamsForRealExams.values()]; } + + // Method to iterate through the map and get all student exams + getAllStudentExams(): StudentExam[] { + const allStudentExams: StudentExam[] = []; + this.testExamMap.forEach((studentExams) => { + studentExams.forEach((studentExam) => { + allStudentExams.push(studentExam); + }); + }); + return allStudentExams; + } + + // Creating attempt indices for student exams + getIndicesForStudentExams(numberOfStudentExams: number): number[] { + const indices: number[] = []; + for (let i = 1; i <= numberOfStudentExams; i++) { + indices.push(i); + } + return indices; + } + + getNumberOfAttemptsForTestExam(exam: Exam): number { + const studentExams = this.testExamMap.get(exam.id!); + return studentExams ? studentExams.length : 0; + } + + /** + * Calculate the individual working time for every submitted StudentExam. As the StudentExam needs to be submitted, the + * working time cannot change. + * For the latest StudentExam, which is still within the allowed working time, a subscription is used to periodically check this. + */ + calculateIndividualWorkingTimeForTestExams(studentExam: StudentExam, latestExam: boolean) { + if (studentExam.started && studentExam.submitted && studentExam.startedDate && studentExam.submissionDate) { + this.withinWorkingTime = false; + } else if (latestExam) { + // A subscription is used here to limit the number of calls for the countdown of the remaining workingTime. + this.studentExamState = interval(1000).subscribe(() => { + this.isWithinWorkingTime(studentExam, studentExam.exam!); + // If the StudentExam is no longer within the working time, the subscription can be unsubscribed, as the state will not change anymore + if (!this.withinWorkingTime) { + this.unsubscribeFromExamStateSubscription(); + } + }); + } else { + this.withinWorkingTime = false; + } + } + + /** + * Used to unsubscribe from the studentExamState Subscriptions + */ + unsubscribeFromExamStateSubscription() { + this.studentExamState?.unsubscribe(); + } + + /** + * Determines if the given StudentExam is (still) within the working time + */ + isWithinWorkingTime(studentExam: StudentExam, exam: Exam) { + if (studentExam.started && !studentExam.submitted && studentExam.startedDate && exam.workingTime) { + const endDate = dayjs(studentExam.startedDate).add(exam.workingTime, 'seconds'); + this.withinWorkingTime = dayjs(endDate).isAfter(dayjs()); + } + } } diff --git a/src/main/webapp/app/overview/course-overview.service.ts b/src/main/webapp/app/overview/course-overview.service.ts index 0432dc2e361b..1fb95997fc82 100644 --- a/src/main/webapp/app/overview/course-overview.service.ts +++ b/src/main/webapp/app/overview/course-overview.service.ts @@ -245,6 +245,12 @@ export class CourseOverviewService { return exams.map((exam, index) => this.mapExamToSidebarCardElement(exam, studentExams?.[index])); } + mapTestExamAttemptsToSidebarCardElements(attempts?: StudentExam[], indices?: number[]) { + if (attempts && indices) { + return attempts.map((attempt, index) => this.mapAttemptToSidebarCardElement(attempt, index)); + } + } + mapConversationsToSidebarCardElements(conversations: ConversationDTO[]) { return conversations.map((conversation) => this.mapConversationToSidebarCardElement(conversation)); } @@ -295,10 +301,10 @@ export class CourseOverviewService { return exerciseCardItem; } - mapExamToSidebarCardElement(exam: Exam, studentExam?: StudentExam): SidebarCardElement { + mapExamToSidebarCardElement(exam: Exam, studentExam?: StudentExam, numberOfAttempts?: number): SidebarCardElement { const examCardItem: SidebarCardElement = { title: exam.title ?? '', - id: exam.id ?? '', + id: (exam.testExam ? exam.id + '/test-exam/' + 'start' : exam.id) ?? '', icon: faGraduationCap, subtitleLeft: exam.moduleNumber ?? '', startDateWithTime: exam.startDate, @@ -306,6 +312,23 @@ export class CourseOverviewService { studentExam: studentExam, attainablePoints: exam.examMaxPoints ?? 0, size: 'L', + isAttempt: false, + testExam: exam.testExam, + attempts: numberOfAttempts ?? 0, + }; + return examCardItem; + } + + mapAttemptToSidebarCardElement(attempt: StudentExam, index: number): SidebarCardElement { + const examCardItem: SidebarCardElement = { + title: attempt.exam!.title ?? '', + id: attempt.exam!.id + '/test-exam/' + attempt.id, + icon: faGraduationCap, + subtitleLeft: this.translate.instant('artemisApp.courseOverview.sidebar.testExamAttempt') + ' ' + index, + submissionDate: attempt.submissionDate, + usedWorkingTime: this.calculateUsedWorkingTime(attempt), + size: 'L', + isAttempt: true, }; return examCardItem; } @@ -370,4 +393,14 @@ export class CourseOverviewService { setSidebarCollapseState(storageId: string, isCollapsed: boolean) { localStorage.setItem('sidebar.collapseState.' + storageId, JSON.stringify(isCollapsed)); } + + calculateUsedWorkingTime(studentExam: StudentExam): number { + let usedWorkingTime = 0; + if (studentExam.exam!.testExam && studentExam.started && studentExam.submitted && studentExam.workingTime && studentExam.startedDate && studentExam.submissionDate) { + const regularExamDuration = studentExam.workingTime; + // As students may submit during the grace period, the workingTime is limited to the regular exam duration + usedWorkingTime = Math.min(regularExamDuration, dayjs(studentExam.submissionDate).diff(dayjs(studentExam.startedDate), 'seconds')); + } + return usedWorkingTime; + } } diff --git a/src/main/webapp/app/overview/courses-routing.module.ts b/src/main/webapp/app/overview/courses-routing.module.ts index 3e835cc3d3d8..eb62075765e3 100644 --- a/src/main/webapp/app/overview/courses-routing.module.ts +++ b/src/main/webapp/app/overview/courses-routing.module.ts @@ -256,6 +256,19 @@ const routes: Routes = [ canDeactivate: [PendingChangesGuard], loadChildren: () => import('../exam/participate/exam-participation.module').then((m) => m.ArtemisExamParticipationModule), }, + { + path: ':examId/test-exam/:studentExamId', + component: ExamParticipationComponent, + data: { + authorities: [Authority.USER], + pageTitle: 'overview.exams', + hasSidebar: true, + showRefreshButton: true, + }, + canActivate: [UserRouteAccessService], + canDeactivate: [PendingChangesGuard], + loadChildren: () => import('../exam/participate/exam-participation.module').then((m) => m.ArtemisExamParticipationModule), + }, ], }, { diff --git a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.ts b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.ts index 97dc98b15681..70ccb17724b5 100644 --- a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.ts +++ b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.ts @@ -92,6 +92,9 @@ export class ExerciseDetailsStudentActionsComponent implements OnInit, OnChanges this.repositoryLink += `/${this.exercise.id}`; } if (this.repositoryLink.includes('exams')) { + if (this.repositoryLink.includes('test-exam')) { + this.repositoryLink = this.repositoryLink.replace('/test-exam/start', ''); + } this.repositoryLink += `/exercises/${this.exercise.id}`; } if (this.repositoryLink.includes('dashboard')) { diff --git a/src/main/webapp/app/shared/sidebar/sidebar-card-item/sidebar-card-item.component.html b/src/main/webapp/app/shared/sidebar/sidebar-card-item/sidebar-card-item.component.html index 15f1ecdd6e90..6e2eb7db4993 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar-card-item/sidebar-card-item.component.html +++ b/src/main/webapp/app/shared/sidebar/sidebar-card-item/sidebar-card-item.component.html @@ -11,40 +11,71 @@
-
+
{{ sidebarItem.subtitleLeft }}
- + - @if (sidebarItem.startDateWithTime) { - {{ sidebarItem.startDateWithTime | artemisDate: 'long-date' }} + @if (sidebarItem.isAttempt) { + {{ sidebarItem.submissionDate | artemisDate: 'long-date' }} - - {{ sidebarItem.startDateWithTime | artemisDate: 'time' }} - } - -
-
-
- - - @if (sidebarItem.studentExam) { - - } @else if (sidebarItem.workingTime) { - {{ sidebarItem.workingTime | artemisDurationFromSeconds }} + {{ sidebarItem.submissionDate | artemisDate: 'time' }} + } @else { + @if (sidebarItem.startDateWithTime) { + {{ sidebarItem.startDateWithTime | artemisDate: 'long-date' }} + - + {{ sidebarItem.startDateWithTime | artemisDate: 'time' }} + } }

-
- - - @if (sidebarItem.attainablePoints) { - {{ sidebarItem.attainablePoints }} - } - -
+ @if (sidebarItem.isAttempt) { +
+ + + @if (sidebarItem.usedWorkingTime) { + {{ sidebarItem.usedWorkingTime | artemisDurationFromSeconds }} + } @else { + {{ '-' }} + } + +
+ } @else { +
+ + + @if (sidebarItem.studentExam) { + + } @else if (sidebarItem.workingTime) { + {{ sidebarItem.workingTime | artemisDurationFromSeconds }} + } + +
+
+ @if (sidebarItem.testExam) { +
+ + + {{ sidebarItem.attempts }} + +
+ } @else { +
+ + + @if (sidebarItem.attainablePoints) { + {{ sidebarItem.attainablePoints }} + } + +
+ } + } } @else {
; @@ -123,9 +123,29 @@ export interface SidebarCardElement { */ attainablePoints?: number; /** - * Set for Exam, indetifies the current status of an exam exercise for exam sidebar + * Set for Exam, identifies the current status of an exam exercise for exam sidebar */ rightIcon?: IconProp; + /** + * Set for Exam, identifies if it is a test exam attempt + */ + isAttempt?: boolean; + /** + * Set For Exam, identifies the number of attempts for each test exam + */ + attempts?: number; + /** + * Set For Exam, identifies if it is a test exam + */ + testExam?: boolean; + /** + * Set For Exam, identifies the submission date for an attempt of a test exam + */ + submissionDate?: dayjs.Dayjs; + /** + * Set For Exam, identifies used working time of a student for an attempt of a test exam + */ + usedWorkingTime?: number; /** * Set for Conversation. Will be removed after refactoring */ diff --git a/src/main/webapp/i18n/de/exam.json b/src/main/webapp/i18n/de/exam.json index c8644192874a..609faad63cbe 100644 --- a/src/main/webapp/i18n/de/exam.json +++ b/src/main/webapp/i18n/de/exam.json @@ -57,6 +57,7 @@ "closed": "Testklausur beendet", "notSubmitted": "Du hast deine Testklausur nicht rechtzeitig eingereicht!", "reviewAttempt": "Versuch #{{attempt}}", + "resume": "Versuch fortführen", "resumeAttempt": "Versuch #{{attempt}} fortführen", "workingTimeLeft": "Verbleibende Arbeitszeit: ", "workingTimeCalculated": "Genutzte Arbeitszeit: ", @@ -299,7 +300,7 @@ "exercisePoints": "Punkte", "exerciseName": "Name", "noStudentExam": "Du bist nicht für die Klausur angemeldet. Bitte wende dich an die entsprechende Lehrkraft.", - "noFurtherAttempts": "Derzeit sind keine weiteren Versuche für die Testklausur möglich. Um deine Abgabe zu betrachten, navigiere zur Klausurseite ", + "noFurtherAttempts": "Derzeit sind keine weiteren Versuche für die Testklausur möglich. Um deine Abgaben zu betrachten, navigiere zur Klausurseite ", "atLeastTutorStudentExam": "Da du nicht Studierende:r bist, kannst du nicht an der Klausur teilnehmen:", "goToExamManagement": "Zur Klausurverwaltung gehen", "goToExamBack": "Gehe zurück, um die Prüfung weiterzumachen", diff --git a/src/main/webapp/i18n/de/student-dashboard.json b/src/main/webapp/i18n/de/student-dashboard.json index 3c15b1c3987c..b9a16b41224b 100644 --- a/src/main/webapp/i18n/de/student-dashboard.json +++ b/src/main/webapp/i18n/de/student-dashboard.json @@ -57,9 +57,14 @@ "further": "Weitere Übungsgruppen", "real": "Klausuren", "test": "Testklausuren", + "attempt": "Testklausurversuche", + "testExamAttempt": "Versuch", + "attempts": "Versuche", "start": "Beginn", "workingTime": "Bearbeitungszeit", "attainablePoints": "Erreichbare Punkte", + "submissionDate": "Abgabedatum", + "usedWorkingTime": "Gebrauchte Arbeitszeit", "noUpcomingSession": "Kein Termin", "header": "Konversationen", "show": "Konversationen zeigen", diff --git a/src/main/webapp/i18n/en/exam.json b/src/main/webapp/i18n/en/exam.json index 691db17fe332..2becaf9e4c23 100644 --- a/src/main/webapp/i18n/en/exam.json +++ b/src/main/webapp/i18n/en/exam.json @@ -57,6 +57,7 @@ "closed": "Test Exam Closed", "notSubmitted": "You have not submitted your Test Exam on time!", "reviewAttempt": "Attempt #{{attempt}}", + "resume": "Resume Attempt", "resumeAttempt": "Resume Attempt #{{attempt}}", "workingTimeLeft": "Working time left: ", "workingTimeCalculated": "Used working time: ", @@ -299,7 +300,7 @@ "exercisePoints": "Points", "exerciseName": "Name", "noStudentExam": "You are not registered for the exam. Please contact your instructor.", - "noFurtherAttempts": "Currently no further attempts for the Test Exam are possible. To review your submission, navigate back to the Exam page", + "noFurtherAttempts": "Currently no further attempts for the Test Exam are possible. To review your submissions, navigate back to the Exam page", "atLeastTutorStudentExam": "Since you are not a student, you cannot participate in this exam:", "goToExamManagement": "Go to exam management page", "goToExamBack": "Go back to continue the exam", diff --git a/src/main/webapp/i18n/en/student-dashboard.json b/src/main/webapp/i18n/en/student-dashboard.json index 1e51fba7c618..d71f4bc98b62 100644 --- a/src/main/webapp/i18n/en/student-dashboard.json +++ b/src/main/webapp/i18n/en/student-dashboard.json @@ -57,9 +57,14 @@ "further": "Further Tutorial Groups", "real": "Exams", "test": "Test Exams", + "attempt": "Test Exam Attempts", + "testExamAttempt": "Attempt", + "attempts": "Attempts", "start": "Start", "workingTime": "Working Time", "attainablePoints": "Attainable Points", + "submissionDate": "Submission Date", + "usedWorkingTime": "Used Working Time", "noUpcomingSession": "No upcoming Session", "header": "Conversations", "show": "Show conversations", diff --git a/src/test/java/de/tum/cit/aet/artemis/exam/TestExamIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/exam/TestExamIntegrationTest.java index 0853bed96f6c..acfde10473e1 100644 --- a/src/test/java/de/tum/cit/aet/artemis/exam/TestExamIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/exam/TestExamIntegrationTest.java @@ -5,6 +5,7 @@ import static org.mockito.Mockito.verify; import java.net.URI; +import java.time.ZonedDateTime; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -239,4 +240,22 @@ void testGetStudentExamForTestExamForStart_fetchExam_successful() throws Excepti StudentExam studentExamReceived = request.get("/api/courses/" + course2.getId() + "/exams/" + testExam.getId() + "/own-student-exam", HttpStatus.OK, StudentExam.class); assertThat(studentExamReceived).isEqualTo(studentExam5); } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetStudentExamForTestExamForStart_ExamEnded() throws Exception { + testExam1.setEndDate(ZonedDateTime.now().minusHours(5)); + examRepository.save(testExam1); + + request.get("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/own-student-exam", HttpStatus.BAD_REQUEST, StudentExam.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetStudentExamForTestExamForStart_MultipleUnfinishedAttempts() throws Exception { + examUtilService.addStudentExamForTestExam(testExam1, student1); + examUtilService.addStudentExamForTestExam(testExam1, student1); + + request.get("/api/courses/" + course1.getId() + "/exams/" + testExam1.getId() + "/own-student-exam", HttpStatus.INTERNAL_SERVER_ERROR, StudentExam.class); + } } diff --git a/src/test/java/de/tum/cit/aet/artemis/exam/service/ExamAccessServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/exam/service/ExamAccessServiceTest.java index 73467cf82f0a..a7b054fdc7a8 100644 --- a/src/test/java/de/tum/cit/aet/artemis/exam/service/ExamAccessServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/exam/service/ExamAccessServiceTest.java @@ -349,7 +349,7 @@ void testCheckAndGetCourseAndExamAccessForConduction_examBelongsToCourse() { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void testCheckAndGetCourseAndExamAccessForConduction_notRegisteredUser() { - assertThatThrownBy(() -> examAccessService.getExamInCourseElseThrow(course1.getId(), exam2.getId())).isInstanceOf(BadRequestAlertException.class); + assertThatThrownBy(() -> examAccessService.getExamInCourseElseThrow(course2.getId(), exam2.getId())).isInstanceOf(BadRequestAlertException.class); } @Test @@ -395,7 +395,7 @@ void testCheckAndGetCourseAndExamAccessForConduction_registeredUser_studentExamP @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void testCheckAndGetCourseAndExamAccessForConduction_examIsVisible() { + void testCheckAndGetCourseAndExamAccessForConduction_testExamIsVisible() { testExam1.setVisibleDate(ZonedDateTime.now().plusMinutes(5)); examRepository.save(testExam1); assertThatThrownBy(() -> examAccessService.getExamInCourseElseThrow(course1.getId(), testExam1.getId())).isInstanceOf(AccessForbiddenException.class); @@ -412,12 +412,28 @@ void testGetExamInCourseElseThrow_noCourseAccess() { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void testGetExamInCourseElseThrow_notVisible() { + void testGetExamInCourseElseThrow_testExamNotVisible() { testExam1.setVisibleDate(ZonedDateTime.now().plusHours(5)); examRepository.save(testExam1); assertThatThrownBy(() -> examAccessService.getExamInCourseElseThrow(course1.getId(), testExam1.getId())).isInstanceOf(AccessForbiddenException.class); } + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetExamInCourseElseThrow_testExamEnded() { + testExam1.setEndDate(ZonedDateTime.now().minusHours(5)); + examRepository.save(testExam1); + assertThatThrownBy(() -> examAccessService.getExamInCourseElseThrow(course1.getId(), testExam1.getId())).isInstanceOf(BadRequestAlertException.class); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testGetExamInCourseElseThrow_multipleUnfinishedStudentExams() { + User user = studentExamForTestExam1.getUser(); + examUtilService.addStudentExamForTestExam(testExam1, user); + assertThatThrownBy(() -> examAccessService.getExamInCourseElseThrow(course1.getId(), testExam1.getId())).isInstanceOf(IllegalStateException.class); + } + @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void testGetExamInCourseElseThrow_success_studentExamPresent() { diff --git a/src/test/java/de/tum/cit/aet/artemis/exam/service/ExamServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/exam/service/ExamServiceTest.java index 860576490fb0..430527fbdba6 100644 --- a/src/test/java/de/tum/cit/aet/artemis/exam/service/ExamServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/exam/service/ExamServiceTest.java @@ -263,6 +263,7 @@ void testThrowsExceptionIfNotPublished() { @Test @WithMockUser(username = "instructor1", roles = "INSTRUCTOR") void testDoesNotThrowExceptionForInstructors() { + studentExam.setId(1L); studentExam.setSubmitted(false); studentExam.getExam().setPublishResultsDate(ZonedDateTime.now().plusDays(5)); studentExam.getExam().setTestExam(true); // test runs are an edge case where instructors want to have access before the publishing date of results diff --git a/src/test/java/de/tum/cit/aet/artemis/exercise/participation/util/ParticipationUtilService.java b/src/test/java/de/tum/cit/aet/artemis/exercise/participation/util/ParticipationUtilService.java index 1d0f5953f3e9..fc9c5b30740d 100644 --- a/src/test/java/de/tum/cit/aet/artemis/exercise/participation/util/ParticipationUtilService.java +++ b/src/test/java/de/tum/cit/aet/artemis/exercise/participation/util/ParticipationUtilService.java @@ -897,7 +897,8 @@ public void mockCreationOfExerciseParticipation(boolean useGradedParticipationOf public void mockCreationOfExerciseParticipation(String templateRepoName, ProgrammingExercise programmingExercise, VersionControlService versionControlService, ContinuousIntegrationService continuousIntegrationService) throws URISyntaxException { var someURL = new VcsRepositoryUri("http://vcs.fake.fake"); - doReturn(someURL).when(versionControlService).copyRepository(any(String.class), eq(templateRepoName), any(String.class), any(String.class), any(String.class)); + doReturn(someURL).when(versionControlService).copyRepository(any(String.class), eq(templateRepoName), any(String.class), any(String.class), any(String.class), + any(Integer.class)); mockCreationOfExerciseParticipationInternal(programmingExercise, versionControlService, continuousIntegrationService); } @@ -912,7 +913,7 @@ public void mockCreationOfExerciseParticipation(String templateRepoName, Program public void mockCreationOfExerciseParticipation(ProgrammingExercise programmingExercise, VersionControlService versionControlService, ContinuousIntegrationService continuousIntegrationService) throws URISyntaxException { var someURL = new VcsRepositoryUri("http://vcs.fake.fake"); - doReturn(someURL).when(versionControlService).copyRepository(any(String.class), any(), any(String.class), any(String.class), any(String.class)); + doReturn(someURL).when(versionControlService).copyRepository(any(String.class), any(), any(String.class), any(String.class), any(String.class), any(Integer.class)); mockCreationOfExerciseParticipationInternal(programmingExercise, versionControlService, continuousIntegrationService); } diff --git a/src/test/java/de/tum/cit/aet/artemis/exercise/service/ParticipationServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/exercise/service/ParticipationServiceTest.java index 216c38d8e088..3c27d603ea06 100644 --- a/src/test/java/de/tum/cit/aet/artemis/exercise/service/ParticipationServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/exercise/service/ParticipationServiceTest.java @@ -151,23 +151,6 @@ void testGetBuildJobsForResultsOfParticipation() throws Exception { assertThat(programmingSubmission.getResults()).isNullOrEmpty(); } - @Test - @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void testStartExerciseWithInitializationDate_newParticipation() { - Course course = textExerciseUtilService.addCourseWithOneReleasedTextExercise(); - Exercise modelling = course.getExercises().iterator().next(); - Participant participant = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); - ZonedDateTime initializationDate = ZonedDateTime.now().minusHours(5); - - StudentParticipation studentParticipationReceived = participationService.startExerciseWithInitializationDate(modelling, participant, true, initializationDate); - - assertThat(studentParticipationReceived.getExercise()).isEqualTo(modelling); - assertThat(studentParticipationReceived.getStudent()).isPresent(); - assertThat(studentParticipationReceived.getStudent().get()).isEqualTo(participant); - assertThat(studentParticipationReceived.getInitializationDate()).isEqualTo(initializationDate); - assertThat(studentParticipationReceived.getInitializationState()).isEqualTo(InitializationState.INITIALIZED); - } - @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void canStartExerciseWithPracticeParticipationAfterDueDateChange() throws URISyntaxException { @@ -225,8 +208,6 @@ void testStartPracticeMode(boolean useGradedParticipation) throws URISyntaxExcep assertThat(studentParticipationReceived.getStudent()).isPresent(); assertThat(studentParticipationReceived.getStudent().get()).isEqualTo(participant); // Acceptance range, initializationDate is to be set to now() - assertThat(studentParticipationReceived.getInitializationDate()).isAfterOrEqualTo(ZonedDateTime.now().minusSeconds(10)); - assertThat(studentParticipationReceived.getInitializationDate()).isBeforeOrEqualTo(ZonedDateTime.now().plusSeconds(10)); assertThat(studentParticipationReceived.getInitializationState()).isEqualTo(InitializationState.INITIALIZED); } diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/RepositoryUriTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/RepositoryUriTest.java index bf124d494ccc..7876c0fd1d37 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/RepositoryUriTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/RepositoryUriTest.java @@ -72,6 +72,20 @@ void testLocalRepositoryPath() throws Exception { assertThat(uri.isPracticeRepository()).isFalse(); } + @Test + void testLocalRepositoryPath_testExamAttempt() throws Exception { + String projectKey = "projectX23"; + String repositorySlug = "projectX23-my-repo"; + URL localVCBaseUrl = new URI("https://artemis.cit.tum.de").toURL(); + + LocalVCRepositoryUri uri = new LocalVCRepositoryUri(projectKey, repositorySlug, localVCBaseUrl); + + assertThat(uri.getProjectKey()).isEqualTo(projectKey); + assertThat(uri.getRepositoryTypeOrUserName()).isEqualTo("my-repo"); + assertThat(uri.isPracticeRepository()).isFalse(); + assertThat(uri.getURI().toString()).isEqualTo("https://artemis.cit.tum.de/git/projectX23/projectX23-my-repo.git"); + } + @Test void testRemoteRepositoryPath() throws Exception { Path repositoryPath = Paths.get("/remote/path/projectY/projectY-repo"); diff --git a/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts b/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts index 1e4bda79525a..b0e02667bf4c 100644 --- a/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts +++ b/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts @@ -282,7 +282,7 @@ describe('ExamParticipationComponent', () => { expect(comp.exam).toEqual(studentExam.exam); }); - it('should load existing testExam if studentExam id is defined', () => { + it('should load existing testExam if studentExam id is start', () => { const studentExam = new StudentExam(); studentExam.exam = new Exam(); studentExam.exam.testExam = true; @@ -291,7 +291,7 @@ describe('ExamParticipationComponent', () => { studentExam.id = 4; const studentExamWithExercises = new StudentExam(); studentExamWithExercises.id = 4; - TestBed.inject(ActivatedRoute).params = of({ courseId: '1', examId: '2', studentExamId: '4' }); + TestBed.inject(ActivatedRoute).params = of({ courseId: '1', examId: '2', studentExamId: 'start' }); const loadStudentExamSpy = jest.spyOn(examParticipationService, 'getOwnStudentExam').mockReturnValue(of(studentExam)); const loadStudentExamWithExercisesForSummary = jest.spyOn(examParticipationService, 'loadStudentExamWithExercisesForSummary').mockReturnValue(of(studentExamWithExercises)); comp.ngOnInit(); @@ -318,7 +318,7 @@ describe('ExamParticipationComponent', () => { studentExam.ended = true; studentExam.submitted = true; comp.ngOnInit(); - expect(loadStudentExamSpy).toHaveBeenCalledOnce(); + expect(loadStudentExamSpy).not.toHaveBeenCalled(); expect(loadStudentExamWithExercisesForSummary).toHaveBeenCalledOnce(); expect(comp.studentExam).toEqual(studentExamWithExercises); expect(comp.studentExam).not.toEqual(studentExam); @@ -356,7 +356,7 @@ describe('ExamParticipationComponent', () => { }); const course: Course = { isAtLeastTutor: true }; - TestBed.inject(ActivatedRoute).params = of({ courseId: '1', examId: '2', studentExamId: '4' }); + TestBed.inject(ActivatedRoute).params = of({ courseId: '1', examId: '2' }); const loadStudentExamSpy = jest.spyOn(examParticipationService, 'getOwnStudentExam').mockReturnValue(throwError(() => httpError)); const courseStorageServiceSpy = jest.spyOn(courseStorageService, 'getCourse').mockReturnValue(course); comp.ngOnInit(); @@ -372,7 +372,7 @@ describe('ExamParticipationComponent', () => { }); const course: Course = { isAtLeastTutor: true }; - TestBed.inject(ActivatedRoute).params = of({ courseId: '1', examId: '2', studentExamId: '4' }); + TestBed.inject(ActivatedRoute).params = of({ courseId: '1', examId: '2' }); const loadStudentExamSpy = jest.spyOn(examParticipationService, 'getOwnStudentExam').mockReturnValue(throwError(() => httpError)); const courseStorageServiceSpy = jest.spyOn(courseStorageService, 'getCourse').mockReturnValue(undefined); const courseServiceSpy = jest.spyOn(courseService, 'find').mockReturnValue(of(new HttpResponse({ body: course }))); @@ -1083,7 +1083,6 @@ describe('ExamParticipationComponent', () => { const studentExam = new StudentExam(); studentExam.exam = new Exam(); - studentExam.exam.testExam = false; studentExam.exam.startDate = dayjs().subtract(2000, 'seconds'); studentExam.workingTime = 100; studentExam.id = 3; @@ -1092,8 +1091,9 @@ describe('ExamParticipationComponent', () => { TestBed.inject(ActivatedRoute).params = of({ courseId: '1', examId: '2', studentExamId: '3' }); jest.spyOn(examParticipationService, 'getOwnStudentExam').mockReturnValue(of(studentExam)); jest.spyOn(examParticipationService, 'loadStudentExamWithExercisesForSummary').mockReturnValue(of(studentExamWithExercises)); - comp.ngOnInit(); + + comp.testExam = false; comp.loadAndDisplaySummary(); expect(examLayoutStub).toHaveBeenCalledOnce(); @@ -1103,7 +1103,6 @@ describe('ExamParticipationComponent', () => { const examLayoutStub = jest.spyOn(examParticipationService, 'resetExamLayout'); const studentExam = new StudentExam(); studentExam.exam = new Exam(); - studentExam.exam.testExam = true; studentExam.exam.startDate = dayjs().subtract(2000, 'seconds'); studentExam.workingTime = 100; studentExam.id = 3; @@ -1112,8 +1111,9 @@ describe('ExamParticipationComponent', () => { TestBed.inject(ActivatedRoute).params = of({ courseId: '1', examId: '2', studentExamId: '3' }); jest.spyOn(examParticipationService, 'getOwnStudentExam').mockReturnValue(of(studentExam)); jest.spyOn(examParticipationService, 'loadStudentExamWithExercisesForSummary').mockReturnValue(of(studentExamWithExercises)); - comp.ngOnInit(); + + comp.testExam = true; comp.loadAndDisplaySummary(); expect(examLayoutStub).not.toHaveBeenCalledOnce(); diff --git a/src/test/javascript/spec/component/exam/participate/exam-start-information/exam-start-information.component.spec.ts b/src/test/javascript/spec/component/exam/participate/exam-start-information/exam-start-information.component.spec.ts index c675ea69d8c7..8a1c11155da2 100644 --- a/src/test/javascript/spec/component/exam/participate/exam-start-information/exam-start-information.component.spec.ts +++ b/src/test/javascript/spec/component/exam/participate/exam-start-information/exam-start-information.component.spec.ts @@ -141,6 +141,24 @@ describe('ExamStartInformationComponent', () => { expect(component.startDate).toStrictEqual(examStartDate); }); + it('should initialize start date of the test exam correctly', () => { + const examStartDate = dayjs('2022-02-06 02:00:00'); + exam.testExam = true; + component.exam = exam; + component.studentExam = studentExam; + fixture.detectChanges(); + expect(component.startDate).toStrictEqual(examStartDate); + }); + + it('should initialize end date of the test exam correctly', () => { + const examEndDate = dayjs('2022-02-06 02:00:00').add(1, 'hours'); + exam.testExam = true; + component.exam = exam; + component.studentExam = studentExam; + fixture.detectChanges(); + expect(component.endDate).toStrictEqual(examEndDate); + }); + it('should create all information boxes if all information of the exam are set', () => { const exam1 = { id: 1, diff --git a/src/test/javascript/spec/service/exam-participation.service.spec.ts b/src/test/javascript/spec/service/exam-participation.service.spec.ts index 26e52989bb33..14b635e11969 100644 --- a/src/test/javascript/spec/service/exam-participation.service.spec.ts +++ b/src/test/javascript/spec/service/exam-participation.service.spec.ts @@ -19,7 +19,7 @@ import { Result } from 'app/entities/result.model'; import { getLatestSubmissionResult } from 'app/entities/submission.model'; import { StudentExamWithGradeDTO, StudentResult } from 'app/exam/exam-scores/exam-score-dtos.model'; import { GradeType } from 'app/entities/grading-scale.model'; -import { provideHttpClient } from '@angular/common/http'; +import { HttpErrorResponse, HttpHeaders, provideHttpClient } from '@angular/common/http'; describe('ExamParticipationService', () => { let service: ExamParticipationService; @@ -46,6 +46,7 @@ describe('ExamParticipationService', () => { exam = new Exam(); studentExam = new StudentExam(); + studentExam.exercises = []; quizSubmission = new QuizSubmission(); }); @@ -268,4 +269,68 @@ describe('ExamParticipationService', () => { const req = httpMock.expectOne({ method: 'GET' }); req.flush(returnedFromService); }); + + it('should submit a StudentExam successfully', async () => { + const studentExamCopy = Object.assign({}, studentExam); + service + .submitStudentExam(1, 1, studentExamCopy) + .pipe(take(1)) + .subscribe((resp) => expect(resp).toBeUndefined()); + + const req = httpMock.expectOne({ method: 'POST' }); + expect(req.request.url).toBe('api/courses/1/exams/1/student-exams/submit'); + req.flush(null); + }); + + it('should throw error if submission is not in time', async () => { + const errorHeaders = new HttpHeaders({ 'x-null-error': 'submissionNotInTime' }); + const errorResponse = new HttpErrorResponse({ + status: 403, + headers: errorHeaders, + }); + + service + .submitStudentExam(1, 1, studentExam) + .pipe(take(1)) + .subscribe({ + error: (err) => expect(err.message).toBe('artemisApp.studentExam.submissionNotInTime'), + }); + + const req = httpMock.expectOne({ method: 'POST' }); + req.flush('Submission Not In Time', errorResponse); + }); + + it('should throw error if the exam was already submitted', async () => { + const errorHeaders = new HttpHeaders({ 'x-null-error': 'error.alreadySubmitted' }); + const errorResponse = new HttpErrorResponse({ + status: 409, + headers: errorHeaders, + }); + + service + .submitStudentExam(1, 1, studentExam) + .pipe(take(1)) + .subscribe({ + error: (err) => expect(err.message).toBe('artemisApp.studentExam.alreadySubmitted'), + }); + + const req = httpMock.expectOne({ method: 'POST' }); + req.flush('Already Submitted', errorResponse); + }); + + it('should throw a generic error if submission fails for any other reason', async () => { + const errorResponse = new HttpErrorResponse({ + status: 500, + }); + + service + .submitStudentExam(1, 1, studentExam) + .pipe(take(1)) + .subscribe({ + error: (err) => expect(err.message).toBe('artemisApp.studentExam.handInFailed'), + }); + + const req = httpMock.expectOne({ method: 'POST' }); + req.flush('Hand-in failed', errorResponse); + }); });