diff --git a/src/main/java/de/tum/in/www1/artemis/config/Constants.java b/src/main/java/de/tum/in/www1/artemis/config/Constants.java index 787be28fbb74..843466925ca3 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/Constants.java +++ b/src/main/java/de/tum/in/www1/artemis/config/Constants.java @@ -301,6 +301,11 @@ public final class Constants { */ public static final int GROUP_CONVERSATION_HUMAN_READABLE_NAME_LIMIT = 100; + /** + * The name of the topic for notifying the client about changes in the exam working time. + */ + public static final String STUDENT_WORKING_TIME_CHANGE_DURING_CONDUCTION_TOPIC = "/topic/studentExams/%s/working-time-change-during-conduction"; + private Constants() { } } diff --git a/src/main/java/de/tum/in/www1/artemis/domain/exam/Exam.java b/src/main/java/de/tum/in/www1/artemis/domain/exam/Exam.java index 984c4c4fb1b8..90c820a517d6 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/exam/Exam.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/exam/Exam.java @@ -1,5 +1,6 @@ package de.tum.in.www1.artemis.domain.exam; +import java.time.Duration; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.HashSet; @@ -192,6 +193,14 @@ public void setEndDate(@NotNull ZonedDateTime endDate) { this.endDate = endDate; } + /** + * @return the duration of the exam in seconds + */ + @JsonIgnore + public int getDuration() { + return Math.toIntExact(Duration.between(getStartDate(), getEndDate()).toSeconds()); + } + public ZonedDateTime getPublishResultsDate() { return publishResultsDate; } diff --git a/src/main/java/de/tum/in/www1/artemis/service/exam/StudentExamService.java b/src/main/java/de/tum/in/www1/artemis/service/exam/StudentExamService.java index dd46b5396650..25a4dae659e9 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/exam/StudentExamService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/exam/StudentExamService.java @@ -39,7 +39,9 @@ import de.tum.in.www1.artemis.service.*; import de.tum.in.www1.artemis.service.programming.ProgrammingExerciseParticipationService; import de.tum.in.www1.artemis.service.programming.ProgrammingTriggerService; +import de.tum.in.www1.artemis.service.scheduled.ProgrammingExerciseScheduleService; import de.tum.in.www1.artemis.service.util.ExamExerciseStartPreparationStatus; +import de.tum.in.www1.artemis.service.util.Tuple; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; @@ -51,8 +53,6 @@ public class StudentExamService { private static final String EXAM_EXERCISE_START_STATUS_TOPIC = "/topic/exams/%s/exercise-start-status"; - private static final String WORKING_TIME_CHANGE_DURING_CONDUCTION_TOPIC = "/topic/studentExams/%s/working-time-change-during-conduction"; - private final Logger log = LoggerFactory.getLogger(StudentExamService.class); private final ParticipationService participationService; @@ -89,6 +89,8 @@ public class StudentExamService { private final WebsocketMessagingService websocketMessagingService; + private final ProgrammingExerciseScheduleService programmingExerciseScheduleService; + private final TaskScheduler scheduler; public StudentExamService(StudentExamRepository studentExamRepository, UserRepository userRepository, ParticipationService participationService, @@ -97,7 +99,7 @@ public StudentExamService(StudentExamRepository studentExamRepository, UserRepos ProgrammingExerciseParticipationService programmingExerciseParticipationService, SubmissionService submissionService, StudentParticipationRepository studentParticipationRepository, ExamQuizService examQuizService, ProgrammingExerciseRepository programmingExerciseRepository, ProgrammingTriggerService programmingTriggerService, ExamRepository examRepository, CacheManager cacheManager, WebsocketMessagingService websocketMessagingService, - @Qualifier("taskScheduler") TaskScheduler scheduler) { + ProgrammingExerciseScheduleService programmingExerciseScheduleService, @Qualifier("taskScheduler") TaskScheduler scheduler) { this.participationService = participationService; this.studentExamRepository = studentExamRepository; this.userRepository = userRepository; @@ -115,6 +117,7 @@ public StudentExamService(StudentExamRepository studentExamRepository, UserRepos this.examRepository = examRepository; this.cacheManager = cacheManager; this.websocketMessagingService = websocketMessagingService; + this.programmingExerciseScheduleService = programmingExerciseScheduleService; this.scheduler = scheduler; } @@ -646,6 +649,13 @@ private void setUpExerciseParticipationsAndSubmissionsWithInitializationDate(Stu || ExamDateService.getExamProgrammingExerciseUnlockDate(programmingExercise).isBefore(ZonedDateTime.now())) { // Note: only unlock the programming exercise student repository for the affected user (Important: Do NOT invoke unlockAll) programmingExerciseParticipationService.unlockStudentRepositoryAndParticipation(programmingParticipation); + + // This is a special case if "prepare exercise start" was pressed shortly before the exam start + // Normally, the locking operation at the end of the exam gets scheduled during the initial unlocking process + // (see ProgrammingExerciseScheduleService#scheduleIndividualRepositoryAndParticipationLockTasks) + // Since this gets never executed here, we need to manually schedule the locking. + var tupel = new Tuple<>(studentExam.getIndividualEndDate(), programmingParticipation); + programmingExerciseScheduleService.scheduleIndividualRepositoryAndParticipationLockTasks(programmingExercise, Set.of(tupel)); } else { programmingExerciseParticipationService.lockStudentParticipation(programmingParticipation); @@ -786,8 +796,4 @@ private StudentExam generateIndividualStudentExam(Exam exam, User student) { userHashSet.add(student); return studentExamRepository.createRandomStudentExams(exam, userHashSet).get(0); } - - public void notifyStudentAboutWorkingTimeChangeDuringConduction(StudentExam studentExam) { - websocketMessagingService.sendMessage(WORKING_TIME_CHANGE_DURING_CONDUCTION_TOPIC.formatted(studentExam.getId()), studentExam.getWorkingTime()); - } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/messaging/DistributedInstanceMessageSendService.java b/src/main/java/de/tum/in/www1/artemis/service/messaging/DistributedInstanceMessageSendService.java index 49f76d6143b3..23d2bccb8f3d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/messaging/DistributedInstanceMessageSendService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/messaging/DistributedInstanceMessageSendService.java @@ -151,7 +151,13 @@ public void sendAssessedExerciseSubmissionNotificationSchedule(Long exerciseId) } @Override - public void sendExamWorkingTimeChangeDuringConduction(Long studentExamId) { + public void sendExamWorkingTimeChangeDuringConduction(Long examId) { + log.info("Sending schedule to reschedule exam {} to broker.", examId); + sendMessageDelayed(MessageTopic.EXAM_RESCHEDULE_DURING_CONDUCTION, examId); + } + + @Override + public void sendStudentExamIndividualWorkingTimeChangeDuringConduction(Long studentExamId) { log.info("Sending schedule to reschedule student exam {} to broker.", studentExamId); sendMessageDelayed(MessageTopic.STUDENT_EXAM_RESCHEDULE_DURING_CONDUCTION, studentExamId); } @@ -162,6 +168,7 @@ public void sendParticipantScoreSchedule(Long exerciseId, Long participantId, Lo sendMessageDelayed(MessageTopic.PARTICIPANT_SCORE_SCHEDULE, exerciseId, participantId, resultId); } + // NOTE: Don't remove any of the following methods despite the warning. private void sendMessageDelayed(MessageTopic topic, Long payload) { exec.schedule(() -> hazelcastInstance.getTopic(topic.toString()).publish(payload), 1, TimeUnit.SECONDS); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/messaging/InstanceMessageReceiveService.java b/src/main/java/de/tum/in/www1/artemis/service/messaging/InstanceMessageReceiveService.java index b2ccb6808643..214abb550041 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/messaging/InstanceMessageReceiveService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/messaging/InstanceMessageReceiveService.java @@ -146,10 +146,14 @@ public InstanceMessageReceiveService(ProgrammingExerciseRepository programmingEx SecurityUtils.setAuthorizationObject(); processScheduleAssessedExerciseSubmittedNotification((message.getMessageObject())); }); - hazelcastInstance.getTopic(MessageTopic.STUDENT_EXAM_RESCHEDULE_DURING_CONDUCTION.toString()).addMessageListener(message -> { + hazelcastInstance.getTopic(MessageTopic.EXAM_RESCHEDULE_DURING_CONDUCTION.toString()).addMessageListener(message -> { SecurityUtils.setAuthorizationObject(); processExamWorkingTimeChangeDuringConduction(message.getMessageObject()); }); + hazelcastInstance.getTopic(MessageTopic.STUDENT_EXAM_RESCHEDULE_DURING_CONDUCTION.toString()).addMessageListener(message -> { + SecurityUtils.setAuthorizationObject(); + processStudentExamIndividualWorkingTimeChangeDuringConduction(message.getMessageObject()); + }); hazelcastInstance.getTopic(MessageTopic.PARTICIPANT_SCORE_SCHEDULE.toString()).addMessageListener(message -> { SecurityUtils.setAuthorizationObject(); processScheduleParticipantScore(message.getMessageObject()[0], message.getMessageObject()[1], message.getMessageObject()[2]); @@ -286,7 +290,12 @@ public void processScheduleAssessedExerciseSubmittedNotification(Long exerciseId notificationScheduleService.updateSchedulingForAssessedExercisesSubmissions(exercise); } - public void processExamWorkingTimeChangeDuringConduction(Long studentExamId) { + public void processExamWorkingTimeChangeDuringConduction(Long examId) { + log.info("Received reschedule of exam during conduction {}", examId); + programmingExerciseScheduleService.rescheduleExamDuringConduction(examId); + } + + public void processStudentExamIndividualWorkingTimeChangeDuringConduction(Long studentExamId) { log.info("Received reschedule of student exam during conduction {}", studentExamId); programmingExerciseScheduleService.rescheduleStudentExamDuringConduction(studentExamId); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/messaging/InstanceMessageSendService.java b/src/main/java/de/tum/in/www1/artemis/service/messaging/InstanceMessageSendService.java index c4b205c20f10..e3c979bd6cd5 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/messaging/InstanceMessageSendService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/messaging/InstanceMessageSendService.java @@ -155,12 +155,19 @@ public interface InstanceMessageSendService { */ void sendAssessedExerciseSubmissionNotificationSchedule(Long exerciseId); + /** + * Send a message to the main server that the working time of an exam was changed during the conduction and rescheduling might be necessary + * + * @param examId the id of the exam that should be scheduled + */ + void sendExamWorkingTimeChangeDuringConduction(Long examId); + /** * Send a message to the main server that the working time of a student exam was changed during the conduction and rescheduling might be necessary * * @param studentExamId the id of the student exam that should be scheduled */ - void sendExamWorkingTimeChangeDuringConduction(Long studentExamId); + void sendStudentExamIndividualWorkingTimeChangeDuringConduction(Long studentExamId); /** * Send a message to the main server that schedules to update the participant score for this exercise/participant diff --git a/src/main/java/de/tum/in/www1/artemis/service/messaging/MainInstanceMessageSendService.java b/src/main/java/de/tum/in/www1/artemis/service/messaging/MainInstanceMessageSendService.java index 379ee634e60b..fea5e35cc1bb 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/messaging/MainInstanceMessageSendService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/messaging/MainInstanceMessageSendService.java @@ -119,8 +119,13 @@ public void sendAssessedExerciseSubmissionNotificationSchedule(Long exerciseId) } @Override - public void sendExamWorkingTimeChangeDuringConduction(Long studentExamId) { - instanceMessageReceiveService.processExamWorkingTimeChangeDuringConduction(studentExamId); + public void sendExamWorkingTimeChangeDuringConduction(Long examId) { + instanceMessageReceiveService.processExamWorkingTimeChangeDuringConduction(examId); + } + + @Override + public void sendStudentExamIndividualWorkingTimeChangeDuringConduction(Long studentExamId) { + instanceMessageReceiveService.processStudentExamIndividualWorkingTimeChangeDuringConduction(studentExamId); } @Override diff --git a/src/main/java/de/tum/in/www1/artemis/service/messaging/MessageTopic.java b/src/main/java/de/tum/in/www1/artemis/service/messaging/MessageTopic.java index 7e7bb8c05547..5f38c404455d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/messaging/MessageTopic.java +++ b/src/main/java/de/tum/in/www1/artemis/service/messaging/MessageTopic.java @@ -26,6 +26,7 @@ public enum MessageTopic { USER_MANAGEMENT_CANCEL_REMOVE_NON_ACTIVATED_USERS("user-management-cancel-remove-non-activated-users"), EXERCISE_RELEASED_SCHEDULE("exercise-released-schedule"), ASSESSED_EXERCISE_SUBMISSION_SCHEDULE("assessed-exercise-submission-schedule"), + EXAM_RESCHEDULE_DURING_CONDUCTION("exam-reschedule-during-conduction"), STUDENT_EXAM_RESCHEDULE_DURING_CONDUCTION("student-exam-reschedule-during-conduction"), PARTICIPANT_SCORE_SCHEDULE("participant-score-schedule"); // @formatter:on diff --git a/src/main/java/de/tum/in/www1/artemis/service/scheduled/ModelingExerciseScheduleService.java b/src/main/java/de/tum/in/www1/artemis/service/scheduled/ModelingExerciseScheduleService.java index 82428e535279..190685fe395d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/scheduled/ModelingExerciseScheduleService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/scheduled/ModelingExerciseScheduleService.java @@ -20,7 +20,7 @@ import de.tum.in.www1.artemis.domain.enumeration.AssessmentType; import de.tum.in.www1.artemis.domain.enumeration.ExerciseLifecycle; import de.tum.in.www1.artemis.domain.modeling.ModelingExercise; -import de.tum.in.www1.artemis.repository.*; +import de.tum.in.www1.artemis.repository.ModelingExerciseRepository; import de.tum.in.www1.artemis.security.SecurityUtils; import de.tum.in.www1.artemis.service.compass.CompassService; import de.tum.in.www1.artemis.service.exam.ExamDateService; @@ -128,9 +128,7 @@ private void scheduleCourseExercise(ModelingExercise exercise) { // For any course exercise that needsToBeScheduled (buildAndTestAfterDueDate and/or manual assessment) if (exercise.getDueDate() != null && ZonedDateTime.now().isBefore(exercise.getDueDate())) { - scheduleService.scheduleTask(exercise, ExerciseLifecycle.DUE, () -> { - buildModelingClusters(exercise).run(); - }); + scheduleService.scheduleTask(exercise, ExerciseLifecycle.DUE, () -> buildModelingClusters(exercise).run()); log.debug("Scheduled build modeling clusters after due date for Modeling Exercise '{}' (#{}) for {}.", exercise.getTitle(), exercise.getId(), exercise.getDueDate()); } else { @@ -148,9 +146,7 @@ private void scheduleExamExercise(ModelingExercise exercise) { if (ZonedDateTime.now().isBefore(examDateService.getLatestIndividualExamEndDateWithGracePeriod(exam))) { var buildDate = endDate.plusMinutes(EXAM_END_WAIT_TIME_FOR_COMPASS_MINUTES); exercise.setClusterBuildDate(buildDate); - scheduleService.scheduleTask(exercise, ExerciseLifecycle.BUILD_COMPASS_CLUSTERS_AFTER_EXAM, () -> { - buildModelingClusters(exercise).run(); - }); + scheduleService.scheduleTask(exercise, ExerciseLifecycle.BUILD_COMPASS_CLUSTERS_AFTER_EXAM, () -> buildModelingClusters(exercise).run()); } log.debug("Scheduled Exam Modeling Exercise '{}' (#{}).", exercise.getTitle(), exercise.getId()); } @@ -166,7 +162,7 @@ public void scheduleExerciseForInstant(ModelingExercise exercise) { /** * Returns a runnable that, once executed, will build modeling clusters - * + *

* NOTE: this will not build modeling clusters as only a Runnable is returned! * * @param exercise The exercise for which the clusters will be created diff --git a/src/main/java/de/tum/in/www1/artemis/service/scheduled/NotificationScheduleService.java b/src/main/java/de/tum/in/www1/artemis/service/scheduled/NotificationScheduleService.java index e46cc1c87abe..d9bb867f3bc4 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/scheduled/NotificationScheduleService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/scheduled/NotificationScheduleService.java @@ -1,7 +1,7 @@ package de.tum.in.www1.artemis.service.scheduled; import java.time.ZonedDateTime; -import java.util.*; +import java.util.Set; import javax.annotation.PostConstruct; diff --git a/src/main/java/de/tum/in/www1/artemis/service/scheduled/ProgrammingExerciseScheduleService.java b/src/main/java/de/tum/in/www1/artemis/service/scheduled/ProgrammingExerciseScheduleService.java index cae1e773a6f9..d2f102d65778 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/scheduled/ProgrammingExerciseScheduleService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/scheduled/ProgrammingExerciseScheduleService.java @@ -5,10 +5,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.function.BiConsumer; -import java.util.function.Consumer; -import java.util.function.Predicate; -import java.util.function.Supplier; +import java.util.function.*; import java.util.stream.Collectors; import javax.annotation.PostConstruct; @@ -74,6 +71,8 @@ public class ProgrammingExerciseScheduleService implements IExerciseScheduleServ private final GroupNotificationService groupNotificationService; + private final ExamRepository examRepository; + private final StudentExamRepository studentExamRepository; private final ExamDateService examDateService; @@ -84,8 +83,8 @@ public ProgrammingExerciseScheduleService(ScheduleService scheduleService, Progr ProgrammingExerciseTestCaseRepository programmingExerciseTestCaseRepository, ResultRepository resultRepository, ParticipationRepository participationRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseParticipationRepository, Environment env, ProgrammingTriggerService programmingTriggerService, ProgrammingExerciseGradingService programmingExerciseGradingService, GroupNotificationService groupNotificationService, ExamDateService examDateService, - ProgrammingExerciseParticipationService programmingExerciseParticipationService, ExerciseDateService exerciseDateService, StudentExamRepository studentExamRepository, - GitService gitService) { + ProgrammingExerciseParticipationService programmingExerciseParticipationService, ExerciseDateService exerciseDateService, ExamRepository examRepository, + StudentExamRepository studentExamRepository, GitService gitService) { this.scheduleService = scheduleService; this.programmingExerciseRepository = programmingExerciseRepository; this.programmingExerciseTestCaseRepository = programmingExerciseTestCaseRepository; @@ -95,6 +94,7 @@ public ProgrammingExerciseScheduleService(ScheduleService scheduleService, Progr this.programmingTriggerService = programmingTriggerService; this.groupNotificationService = groupNotificationService; this.exerciseDateService = exerciseDateService; + this.examRepository = examRepository; this.studentExamRepository = studentExamRepository; this.examDateService = examDateService; this.programmingExerciseParticipationService = programmingExerciseParticipationService; @@ -382,10 +382,13 @@ private void scheduleExamExercise(ProgrammingExercise exercise) { return; } - // BEFORE EXAM + // The common unlock date of the exam's programming exercises ZonedDateTime unlockDate = ExamDateService.getExamProgrammingExerciseUnlockDate(exercise); + + // BEFORE EXAM if (now.isBefore(unlockDate)) { - // Use the custom date from the exam rather than the of the exercise's lifecycle + // Schedule unlocking of student repositories + // Uses the custom exam unlock date rather than the of the exercise's lifecycle scheduleService.scheduleTask(exercise, ExerciseLifecycle.RELEASE, Set.of(new Tuple<>(unlockDate, unlockAllStudentRepositoriesAndParticipations(exercise)))); } // DURING EXAM @@ -396,6 +399,9 @@ else if (now.isBefore(examDateService.getLatestIndividualExamEndDate(exam))) { var scheduledRunnable = Set.of( new Tuple<>(now.plusSeconds(Constants.SECONDS_AFTER_RELEASE_DATE_FOR_UNLOCKING_STUDENT_EXAM_REPOS), unlockAllStudentRepositoriesAndParticipations(exercise))); scheduleService.scheduleTask(exercise, ExerciseLifecycle.RELEASE, scheduledRunnable); + + // Re-schedule the locking of student repositories based on the individual working time + rescheduleProgrammingExerciseDuringExamConduction(exercise); } // NOTHING TO DO AFTER EXAM @@ -792,46 +798,83 @@ public Runnable unlockAllStudentParticipationsWithEarlierStartDateAndLaterDueDat } /** - * this method schedules individual lock tasks for programming exercises (mostly in the context of exams) + * Schedules individual lock tasks for programming exercises (mostly in the context of exams) * - * @param exercise the programming exercise for which the lock is executed - * @param individualDueDates these are the individual due dates for students taking individual workingTimes of student exams into account + * @param exercise the programming exercise for which the lock is executed + * @param individualParticipationsWithDueDates the set of student participations with their individual due dates */ - private void scheduleIndividualRepositoryAndParticipationLockTasks(ProgrammingExercise exercise, - Set> individualDueDates) { - // 1. Group all participations by due date (TODO use student exams for safety if some participations are not pre-generated) - var participationsGroupedByDueDate = individualDueDates.stream().filter(tuple -> tuple.x() != null) + public void scheduleIndividualRepositoryAndParticipationLockTasks(ProgrammingExercise exercise, + Set> individualParticipationsWithDueDates) { + // 1. Group all participations by due date + // TODO: use student exams for safety if some participations are not pre-generated + var participationsGroupedByDueDate = individualParticipationsWithDueDates.stream().filter(tuple -> tuple.x() != null) .collect(Collectors.groupingBy(Tuple::x, Collectors.mapping(Tuple::y, Collectors.toSet()))); + // 2. Transform those groups into lock-repository tasks with times Set> tasks = participationsGroupedByDueDate.entrySet().stream().map(entry -> { - // Check that this participation is planed to be locked and has still the same due date + // Check that this participation is planned to be locked and has still the same due date Predicate lockingCondition = participation -> entry.getValue().contains(participation) && entry.getKey().equals(studentExamRepository.getIndividualDueDate(exercise, participation)); + var task = lockStudentRepositoriesAndParticipations(exercise, lockingCondition); return new Tuple<>(entry.getKey(), task); }).collect(Collectors.toSet()); + // 3. Schedule all tasks scheduleService.scheduleTask(exercise, ExerciseLifecycle.DUE, tasks); } /** - * Reschedules all programming exercises in this student exam, since the working time was changed + * Reschedules all programming exercise related tasks in the given exam. + * + * @param examId the id of the exam + */ + public void rescheduleExamDuringConduction(Long examId) { + Exam exam = examRepository.findWithExerciseGroupsExercisesParticipationsAndSubmissionsById(examId).orElseThrow(NoSuchElementException::new); + + // get all programming exercises in the exam + exam.getExerciseGroups().stream().flatMap(exerciseGroup -> exerciseGroup.getExercises().stream()).filter(exercise -> exercise instanceof ProgrammingExercise) + .map(exercise -> (ProgrammingExercise) exercise) + // schedule repository locks for each programming exercise + .forEach(this::rescheduleProgrammingExerciseDuringExamConduction); + } + + private void rescheduleProgrammingExerciseDuringExamConduction(ProgrammingExercise programmingExercise) { + // Collect the individual due date of each student participation + var participationsWithDueDate = programmingExercise.getStudentParticipations().stream().filter(ProgrammingExerciseStudentParticipation.class::isInstance) + .map(studentParticipation -> { + var dueDate = studentExamRepository.getIndividualDueDate(programmingExercise, studentParticipation); + return new Tuple<>(dueDate, (ProgrammingExerciseStudentParticipation) studentParticipation); + }).collect(Collectors.toSet()); + + // Re-schedule the lock operation at the individual end dates + scheduleIndividualRepositoryAndParticipationLockTasks(programmingExercise, participationsWithDueDate); + } + + /** + * Reschedules all programming exercises related tasks in the given student exam. * * @param studentExamId the id of the student exam */ public void rescheduleStudentExamDuringConduction(Long studentExamId) { StudentExam studentExam = studentExamRepository.findWithExercisesParticipationsSubmissionsById(studentExamId, false).orElseThrow(NoSuchElementException::new); - List programmingExercises = studentExam.getExercises().stream().filter(exercise -> exercise instanceof ProgrammingExercise) - .map(exercise -> (ProgrammingExercise) exercise).toList(); - programmingExercises.forEach(programmingExercise -> { - Optional participation = programmingExercise.getStudentParticipations().stream().findFirst(); - if (participation.isEmpty() || !(participation.get() instanceof ProgrammingExerciseStudentParticipation programmingParticipation)) { + // iterate over all programming exercises and its student participation in the student's exam + studentExam.getExercises().stream().filter(exercise -> exercise instanceof ProgrammingExercise).map(exercise -> (ProgrammingExercise) exercise).forEach(exercise -> { + var participations = exercise.getStudentParticipations(); + // if the student does not participate in the programming exercise, skip it + if (participations.isEmpty()) { return; } - ZonedDateTime dueDate = studentExamRepository.getIndividualDueDate(programmingExercise, programmingParticipation); - - scheduleIndividualRepositoryAndParticipationLockTasks(programmingExercise, Set.of(new Tuple<>(dueDate, programmingParticipation))); + StudentParticipation participation = participations.iterator().next(); + // if it's not a programming exercise participation, skip it + if (!(participation instanceof ProgrammingExerciseStudentParticipation programmingParticipation)) { + return; + } + // get the individual due date of the student's participation in the programming exercise + ZonedDateTime dueDate = studentExamRepository.getIndividualDueDate(exercise, programmingParticipation); + // schedule repository locks for each programming exercise + scheduleIndividualRepositoryAndParticipationLockTasks(exercise, Set.of(new Tuple<>(dueDate, programmingParticipation))); }); } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java index 9f42c7385907..f786676727d5 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/ExamResource.java @@ -1,5 +1,6 @@ package de.tum.in.www1.artemis.web.rest; +import static de.tum.in.www1.artemis.config.Constants.STUDENT_WORKING_TIME_CHANGE_DURING_CONDUCTION_TOPIC; import static de.tum.in.www1.artemis.service.util.TimeLogUtil.formatDurationFrom; import static java.time.ZonedDateTime.now; @@ -9,12 +10,12 @@ import java.net.URI; import java.net.URISyntaxException; import java.nio.file.Path; -import java.time.Duration; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.*; import javax.validation.constraints.NotNull; +import javax.ws.rs.BadRequestException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,10 +25,7 @@ import org.springframework.core.io.Resource; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; +import org.springframework.http.*; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; @@ -42,10 +40,7 @@ import de.tum.in.www1.artemis.repository.metis.conversation.ChannelRepository; import de.tum.in.www1.artemis.security.Role; import de.tum.in.www1.artemis.security.annotations.*; -import de.tum.in.www1.artemis.service.AssessmentDashboardService; -import de.tum.in.www1.artemis.service.AuthorizationCheckService; -import de.tum.in.www1.artemis.service.ProfileService; -import de.tum.in.www1.artemis.service.SubmissionService; +import de.tum.in.www1.artemis.service.*; import de.tum.in.www1.artemis.service.dto.StudentDTO; import de.tum.in.www1.artemis.service.exam.*; import de.tum.in.www1.artemis.service.feature.Feature; @@ -113,6 +108,8 @@ public class ExamResource { private final ChannelService channelService; + private final WebsocketMessagingService websocketMessagingService; + private final ExerciseRepository exerciseRepository; private final ExamSessionService examSessionService; @@ -122,7 +119,8 @@ public ExamResource(ProfileService profileService, UserRepository userRepository SubmissionService submissionService, AuthorizationCheckService authCheckService, ExamDateService examDateService, TutorParticipationRepository tutorParticipationRepository, AssessmentDashboardService assessmentDashboardService, ExamRegistrationService examRegistrationService, StudentExamRepository studentExamRepository, ExamImportService examImportService, CustomAuditEventRepository auditEventRepository, ChannelService channelService, - ChannelRepository channelRepository, ExerciseRepository exerciseRepository, ExamSessionService examSessionRepository) { + ChannelRepository channelRepository, WebsocketMessagingService websocketMessagingService, ExerciseRepository exerciseRepository, + ExamSessionService examSessionRepository) { this.profileService = profileService; this.userRepository = userRepository; this.courseRepository = courseRepository; @@ -142,6 +140,7 @@ public ExamResource(ProfileService profileService, UserRepository userRepository this.auditEventRepository = auditEventRepository; this.channelService = channelService; this.channelRepository = channelRepository; + this.websocketMessagingService = websocketMessagingService; this.exerciseRepository = exerciseRepository; this.examSessionService = examSessionRepository; } @@ -240,6 +239,72 @@ public ResponseEntity updateExam(@PathVariable Long courseId, @RequestBody return ResponseEntity.ok(savedExam); } + /** + * PATCH /courses/{courseId}/exams/{examId}/working-time : Update the working time of all exams + * + * @param courseId the course ID to which the exam belong to + * @param examId the exam ID the working time should be extended for + * @param workingTimeChange the working time change in seconds (can be positive or negative, but must not be 0) + * @return the ResponseEntity with status 200 (OK) and with the updated exam as body + */ + @PatchMapping("/courses/{courseId}/exams/{examId}/working-time") + @EnforceAtLeastInstructor + public ResponseEntity updateExamWorkingTime(@PathVariable Long courseId, @PathVariable Long examId, @RequestBody Integer workingTimeChange) { + log.debug("REST request to update the working time of exam with id {}", examId); + + examAccessService.checkCourseAndExamAccessForInstructorElseThrow(courseId, examId); + + if (workingTimeChange == 0) { + throw new BadRequestException(); + } + + var now = now(); + + // We have to get exercise groups as `scheduleModelingExercises` needs them + Exam exam = examService.findByIdWithExerciseGroupsAndExercisesElseThrow(examId); + var originalExamDuration = exam.getDuration(); + + // 1. Update the end date & working time of the exam + exam.setEndDate(exam.getEndDate().plusSeconds(workingTimeChange)); + exam.setWorkingTime(exam.getWorkingTime() + workingTimeChange); + examRepository.save(exam); + + // 2. Re-calculate the working times of all student exams + var studentExams = studentExamRepository.findByExamId(examId); + for (var studentExam : studentExams) { + Integer originalStudentWorkingTime = studentExam.getWorkingTime(); + int originalTimeExtension = originalStudentWorkingTime - originalExamDuration; + // NOTE: take the original working time extensions into account + if (originalTimeExtension == 0) { + studentExam.setWorkingTime(originalStudentWorkingTime + workingTimeChange); + } + else { + double relativeTimeExtension = (double) originalTimeExtension / (double) originalExamDuration; + int newNormalWorkingTime = originalExamDuration + workingTimeChange; + int timeAdjustment = Math.toIntExact(Math.round(newNormalWorkingTime * relativeTimeExtension)); + int adjustedWorkingTime = Math.max(newNormalWorkingTime + timeAdjustment, 0); + studentExam.setWorkingTime(adjustedWorkingTime); + } + var savedStudentExam = studentExamRepository.save(studentExam); + // NOTE: if the exam is already visible, notify the student about the working time change + if (now.isAfter(exam.getVisibleDate())) { + websocketMessagingService.sendMessage(STUDENT_WORKING_TIME_CHANGE_DURING_CONDUCTION_TOPIC.formatted(savedStudentExam.getId()), savedStudentExam.getWorkingTime()); + } + } + + // NOTE: if the exam is already visible, notify instances about the working time change + if (now.isAfter(exam.getVisibleDate())) { + instanceMessageSendService.sendExamWorkingTimeChangeDuringConduction(exam.getId()); + } + + if (now.isBefore(examDateService.getLatestIndividualExamEndDate(exam))) { + // potentially re-schedule clustering of modeling submissions (in case Compass is active) + examService.scheduleModelingExercises(exam); + } + + return ResponseEntity.ok(exam); + } + /** * POST /courses/{courseId}/exam-import : Imports a new exam with exercises. * @@ -338,19 +403,19 @@ else if (!exam.getVisibleDate().isBefore(exam.getStartDate()) || !exam.getStartD * @param exam the exam to be checked */ private void checkExamForWorkingTimeConflictsElseThrow(Exam exam) { - int differenceStartEndDate = Math.toIntExact(Duration.between(exam.getStartDate(), exam.getEndDate()).toSeconds()); + var examDuration = exam.getDuration(); if (exam.isTestExam()) { - if (exam.getWorkingTime() > differenceStartEndDate || exam.getWorkingTime() < 1) { + if (exam.getWorkingTime() > examDuration || exam.getWorkingTime() < 1) { throw new BadRequestAlertException("For TestExams, the working time must be at least 1 and at most the duration of the working window.", ENTITY_NAME, "examTimes"); } } - else if (exam.getWorkingTime() != differenceStartEndDate) { + else if (exam.getWorkingTime() != examDuration) { /* * Set the working time to the time difference for real exams, if not done by the client. This can be an issue if the working time calculation in the client is not * performed (e.g. for Cypress-2E2-Tests). However, since the working time currently depends on the start- and end-date, we can do a server-side assignment */ - exam.setWorkingTime(differenceStartEndDate); + exam.setWorkingTime(examDuration); } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/StudentExamResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/StudentExamResource.java index 893d688113e3..837607464077 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/StudentExamResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/StudentExamResource.java @@ -1,7 +1,9 @@ package de.tum.in.www1.artemis.web.rest; import static de.tum.in.www1.artemis.config.Constants.EXAM_START_WAIT_TIME_MINUTES; +import static de.tum.in.www1.artemis.config.Constants.STUDENT_WORKING_TIME_CHANGE_DURING_CONDUCTION_TOPIC; import static de.tum.in.www1.artemis.service.util.TimeLogUtil.formatDurationFrom; +import static java.time.ZonedDateTime.now; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; @@ -39,10 +41,7 @@ import de.tum.in.www1.artemis.service.util.ExamExerciseStartPreparationStatus; import de.tum.in.www1.artemis.service.util.HttpRequestUtils; import de.tum.in.www1.artemis.web.rest.dto.StudentExamWithGradeDTO; -import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; -import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; -import de.tum.in.www1.artemis.web.rest.errors.ConflictException; -import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; +import de.tum.in.www1.artemis.web.rest.errors.*; import de.tum.in.www1.artemis.web.rest.util.HeaderUtil; /** @@ -84,7 +83,7 @@ public class StudentExamResource { private final InstanceMessageSendService instanceMessageSendService; - private final WebsocketMessagingService messagingService; + private final WebsocketMessagingService websocketMessagingService; private final SubmissionPolicyRepository submissionPolicyRepository; @@ -101,7 +100,7 @@ public StudentExamResource(ExamAccessService examAccessService, ExamDeletionServ StudentExamRepository studentExamRepository, ExamDateService examDateService, ExamSessionService examSessionService, StudentParticipationRepository studentParticipationRepository, ExamRepository examRepository, SubmittedAnswerRepository submittedAnswerRepository, AuthorizationCheckService authorizationCheckService, ExamService examService, InstanceMessageSendService instanceMessageSendService, - WebsocketMessagingService messagingService, SubmissionPolicyRepository submissionPolicyRepository) { + WebsocketMessagingService websocketMessagingService, SubmissionPolicyRepository submissionPolicyRepository) { this.examAccessService = examAccessService; this.examDeletionService = examDeletionService; this.studentExamService = studentExamService; @@ -117,7 +116,7 @@ public StudentExamResource(ExamAccessService examAccessService, ExamDeletionServ this.authorizationCheckService = authorizationCheckService; this.examService = examService; this.instanceMessageSendService = instanceMessageSendService; - this.messagingService = messagingService; + this.websocketMessagingService = websocketMessagingService; this.submissionPolicyRepository = submissionPolicyRepository; } @@ -197,6 +196,7 @@ public ResponseEntity updateWorkingTime(@PathVariable Long courseId examAccessService.checkCourseAndExamAndStudentExamAccessElseThrow(courseId, examId, studentExamId); + var now = now(); if (workingTime <= 0) { throw new BadRequestException(); } @@ -206,12 +206,12 @@ public ResponseEntity updateWorkingTime(@PathVariable Long courseId if (!savedStudentExam.isTestRun()) { Exam exam = examService.findByIdWithExerciseGroupsAndExercisesElseThrow(examId); - if (ZonedDateTime.now().isAfter(exam.getVisibleDate())) { - instanceMessageSendService.sendExamWorkingTimeChangeDuringConduction(studentExamId); - studentExamService.notifyStudentAboutWorkingTimeChangeDuringConduction(savedStudentExam); + if (now.isAfter(exam.getVisibleDate())) { + instanceMessageSendService.sendStudentExamIndividualWorkingTimeChangeDuringConduction(studentExamId); + websocketMessagingService.sendMessage(STUDENT_WORKING_TIME_CHANGE_DURING_CONDUCTION_TOPIC.formatted(savedStudentExam.getId()), savedStudentExam.getWorkingTime()); } - if (ZonedDateTime.now().isBefore(examDateService.getLatestIndividualExamEndDate(exam)) && exam.getStartDate() != null - && ZonedDateTime.now().isBefore(exam.getStartDate().plusSeconds(workingTime))) { + if (now.isBefore(examDateService.getLatestIndividualExamEndDate(exam))) { + // potentially re-schedule clustering of modeling submissions (in case Compass is active) examService.scheduleModelingExercises(exam); } } @@ -255,8 +255,8 @@ public ResponseEntity submitStudentExam(@PathVariable Long courseId, @Path } // checks if student exam is live (after start date, before end date + grace period) - if (!existingStudentExam.isTestRun() && (existingStudentExam.getExam().getStartDate() != null && !ZonedDateTime.now().isAfter(existingStudentExam.getExam().getStartDate()) - || existingStudentExam.getIndividualEndDate() != null && !ZonedDateTime.now().isBefore(existingStudentExam.getIndividualEndDateWithGracePeriod()))) { + if (!existingStudentExam.isTestRun() && (existingStudentExam.getExam().getStartDate() != null && !now().isAfter(existingStudentExam.getExam().getStartDate()) + || existingStudentExam.getIndividualEndDate() != null && !now().isBefore(existingStudentExam.getIndividualEndDateWithGracePeriod()))) { throw new AccessForbiddenException("You can only submit between start and end of the exam."); } @@ -264,7 +264,7 @@ public ResponseEntity submitStudentExam(@PathVariable Long courseId, @Path studentExamService.submitStudentExam(existingStudentExam, studentExamFromClient, currentUser); - messagingService.sendMessage("/topic/exam/" + examId + "/submitted", ""); + websocketMessagingService.sendMessage("/topic/exam/" + examId + "/submitted", ""); log.info("Completed submitStudentExam with {} exercises for user {} in a total time of {}", existingStudentExam.getExercises().size(), currentUser.getLogin(), formatDurationFrom(start)); @@ -307,12 +307,12 @@ public ResponseEntity getStudentExamForConduction(@PathVariable Lon } // students can not fetch the exam until EXAM_START_WAIT_TIME_MINUTES minutes before the exam start, we use the same constant in the client - if (ZonedDateTime.now().plusMinutes(EXAM_START_WAIT_TIME_MINUTES).isBefore(studentExam.getExam().getStartDate())) { + if (now().plusMinutes(EXAM_START_WAIT_TIME_MINUTES).isBefore(studentExam.getExam().getStartDate())) { throw new AccessForbiddenException("Students cannot download the student exams until " + EXAM_START_WAIT_TIME_MINUTES + " minutes before the exam start"); } if (!Boolean.TRUE.equals(studentExam.isStarted())) { - messagingService.sendMessage("/topic/exam/" + examId + "/started", ""); + websocketMessagingService.sendMessage("/topic/exam/" + examId + "/started", ""); } prepareStudentExamForConduction(request, currentUser, studentExam); @@ -635,7 +635,7 @@ private void prepareStudentExamForConduction(HttpServletRequest request, User cu 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 = ZonedDateTime.now().truncatedTo(ChronoUnit.SECONDS); + ZonedDateTime startedDate = now().truncatedTo(ChronoUnit.SECONDS); // Set up new participations for the Exercises and set initialisationDate to the startedDate studentExamService.setUpTestExamExerciseParticipationsAndSubmissions(studentExam, startedDate); @@ -644,7 +644,7 @@ private void prepareStudentExamForConduction(HttpServletRequest request, User cu if (!Boolean.TRUE.equals(studentExam.isStarted()) || studentExam.getStartedDate() == null) { // Mark the student exam as started with now as the start date if it was not started before - var startDate = studentExam.getStartedDate() != null ? studentExam.getStartedDate() : ZonedDateTime.now(); + var startDate = studentExam.getStartedDate() != null ? studentExam.getStartedDate() : now(); studentExam.setStartedAndStartDate(startDate); // send those changes in a modifying query to the database studentExamRepository.startStudentExam(studentExam.getId(), startDate); @@ -717,11 +717,11 @@ public ResponseEntity submitStudentExam(@PathVariable Long courseId if (studentExam.isSubmitted()) { throw new BadRequestException(); } - if (studentExam.getIndividualEndDateWithGracePeriod().isAfter(ZonedDateTime.now())) { + if (studentExam.getIndividualEndDateWithGracePeriod().isAfter(now())) { throw new AccessForbiddenException("Exam", examId); } - ZonedDateTime submissionTime = ZonedDateTime.now(); + ZonedDateTime submissionTime = now(); studentExam.setSubmissionDate(submissionTime); studentExam.setSubmitted(true); @@ -754,7 +754,7 @@ public ResponseEntity unsubmitStudentExam(@PathVariable Long course if (!studentExam.isSubmitted()) { throw new BadRequestException(); } - if (studentExam.getIndividualEndDateWithGracePeriod().isAfter(ZonedDateTime.now())) { + if (studentExam.getIndividualEndDateWithGracePeriod().isAfter(now())) { throw new AccessForbiddenException("Exam", examId); } diff --git a/src/main/webapp/app/exam/manage/exam-management.module.ts b/src/main/webapp/app/exam/manage/exam-management.module.ts index 0e951ffc62d4..a171a6a4dd69 100644 --- a/src/main/webapp/app/exam/manage/exam-management.module.ts +++ b/src/main/webapp/app/exam/manage/exam-management.module.ts @@ -53,10 +53,12 @@ import { FeatureToggleModule } from 'app/shared/feature-toggle/feature-toggle.mo import { BonusComponent } from 'app/grading-system/bonus/bonus.component'; import { ArtemisModePickerModule } from 'app/exercises/shared/mode-picker/mode-picker.module'; import { TitleChannelNameModule } from 'app/shared/form/title-channel-name/title-channel-name.module'; +import { ExamEditWorkingTimeDialogComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time-dialog.component'; import { SuspiciousBehaviorComponent } from './suspicious-behavior/suspicious-behavior.component'; import { SuspiciousSessionsOverviewComponent } from './suspicious-behavior/suspicious-sessions-overview/suspicious-sessions-overview.component'; import { PlagiarismCasesOverviewComponent } from './suspicious-behavior/plagiarism-cases-overview/plagiarism-cases-overview.component'; import { SuspiciousSessionsComponent } from './suspicious-behavior/suspicious-sessions/suspicious-sessions.component'; +import { ExamEditWorkingTimeComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time.component'; const ENTITY_STATES = [...examManagementState]; @@ -119,6 +121,8 @@ const ENTITY_STATES = [...examManagementState]; ExamImportComponent, ExamExerciseImportComponent, BonusComponent, + ExamEditWorkingTimeComponent, + ExamEditWorkingTimeDialogComponent, SuspiciousBehaviorComponent, SuspiciousSessionsOverviewComponent, PlagiarismCasesOverviewComponent, diff --git a/src/main/webapp/app/exam/manage/exam-management.service.ts b/src/main/webapp/app/exam/manage/exam-management.service.ts index fd05d95cb618..42cfe628c057 100644 --- a/src/main/webapp/app/exam/manage/exam-management.service.ts +++ b/src/main/webapp/app/exam/manage/exam-management.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@angular/core'; -import { Router } from '@angular/router'; import { ExamUserDTO } from 'app/entities/exam-user-dto.model'; import { ExamUserAttendanceCheckDTO } from 'app/entities/exam-users-attendance-check-dto.model'; import { filter, map, tap } from 'rxjs/operators'; @@ -31,7 +30,6 @@ export class ExamManagementService { public adminResourceUrl = 'api/admin/courses'; constructor( - private router: Router, private http: HttpClient, private accountService: AccountService, private entityTitleService: EntityTitleService, @@ -61,6 +59,18 @@ export class ExamManagementService { .pipe(map((res: EntityResponseType) => this.processExamResponseFromServer(res))); } + /** + * Update the working time of an exam on the server using a PATCH request. + * @param courseId The course id. + * @param examId The exam id. + * @param workingTimeChange The amount of time in seconds by which the working time should be increased or decreased. It can be positive or negative, but must not be 0. + */ + updateWorkingTime(courseId: number, examId: number, workingTimeChange: number): Observable { + return this.http + .patch(`${this.resourceUrl}/${courseId}/exams/${examId}/working-time`, workingTimeChange, { observe: 'response' }) + .pipe(map((res: EntityResponseType) => this.processExamResponseFromServer(res))); + } + /** * Imports an exam on the server using a PUT request. * @param courseId The course id into which the exam should be imported diff --git a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.html b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.html index 41dbace41247..3b008c1b0cd4 100644 --- a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.html +++ b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-checklist.component.html @@ -182,16 +182,14 @@

{{ 'artemisApp.examManagement.checklist.tableItem.prepareExerciseStart' | artemisTranslate }} - {{ 'artemisApp.examManagement.checklist.descriptionItem.prepareExerciseStart' | artemisTranslate }} -
-
    -
  • - - {{ 'artemisApp.examManagement.checklist.textitems.allExercisesPrepared' | artemisTranslate }} -
  • -
-
+ {{ 'artemisApp.examManagement.checklist.descriptionItem.prepareExerciseStart' | artemisTranslate }} +
+
    +
  • + + {{ 'artemisApp.examManagement.checklist.textitems.allExercisesPrepared' | artemisTranslate }} +
  • +
@@ -208,10 +206,10 @@

{{ 'artemisApp.examManagement.checklist.descriptionItem.examDetails' | artemisTranslate }} +
+ {{ 'artemisApp.examManagement.checklist.testExam.detailsHintForTestExams' | artemisTranslate }} :
    -
    - {{ 'artemisApp.examManagement.checklist.testExam.detailsHintForTestExams' | artemisTranslate }}:
  • @@ -291,7 +289,7 @@

    {{ 'artemisApp.examStatus.conduction.' + (isTestExam ? 'testExam.' : '') + ' " > - {{ 'artemisApp.examManagement.checklist.textitems.startedExam' | artemisTranslate }}: + {{ 'artemisApp.examManagement.checklist.textitems.startedExam' | artemisTranslate }} :
    {{ 'artemisApp.examStatus.conduction.' + (isTestExam ? 'testExam.' : '') + ' examChecklist && examChecklist.numberOfGeneratedStudentExams !== null && examChecklist.numberOfGeneratedStudentExams !== 0 && numberOfSubmitted > 0 " > - {{ 'artemisApp.examManagement.checklist.textitems.submittedExam' | artemisTranslate }}: + {{ 'artemisApp.examManagement.checklist.textitems.submittedExam' | artemisTranslate }} :
    @@ -326,6 +324,18 @@

    {{ 'artemisApp.examStatus.conduction.' + (isTestExam ? 'testExam.' : '') + ' + + 2 + + {{ 'artemisApp.examManagement.checklist.tableItem.editWorkingTime' | artemisTranslate }} + + + {{ 'artemisApp.examManagement.checklist.descriptionItem.editWorkingTime' | artemisTranslate }} + + + + +

    diff --git a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time-dialog.component.html b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time-dialog.component.html new file mode 100644 index 000000000000..36e7d8210cbb --- /dev/null +++ b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time-dialog.component.html @@ -0,0 +1,55 @@ +
    + + + +
    diff --git a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time-dialog.component.ts b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time-dialog.component.ts new file mode 100644 index 000000000000..c0f8a231564d --- /dev/null +++ b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time-dialog.component.ts @@ -0,0 +1,65 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { HttpResponse } from '@angular/common/http'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { faBan, faCheck, faSpinner } from '@fortawesome/free-solid-svg-icons'; + +import { Exam } from 'app/entities/exam.model'; +import { ExamManagementService } from 'app/exam/manage/exam-management.service'; +import { normalWorkingTime } from 'app/exam/participate/exam.utils'; +import dayjs from 'dayjs/esm'; + +@Component({ + selector: 'jhi-edit-working-time-dialog', + templateUrl: './exam-edit-working-time-dialog.component.html', +}) +export class ExamEditWorkingTimeDialogComponent { + @Input() exam: Exam; + @Output() examChange = new EventEmitter(); + + isLoading: boolean; + + // Icons + faBan = faBan; + faSpinner = faSpinner; + faCheck = faCheck; + + confirmEntityName: string; + + workingTimeSeconds = 0; + + get absoluteWorkingTimeDuration() { + const currentWorkingTimeSeconds = normalWorkingTime(this.exam); + if (!currentWorkingTimeSeconds) return undefined; + const duration = dayjs.duration(currentWorkingTimeSeconds + this.workingTimeSeconds, 'seconds'); + return [duration.asHours() >= 1 ? `${Math.floor(duration.asHours())} h` : null, duration.format('m [min] s [s]')].filter(Boolean).join(' '); + } + + constructor( + private activeModal: NgbActiveModal, + private examManagementService: ExamManagementService, + ) {} + + clear(): void { + this.activeModal.close(); + } + + confirmUpdateWorkingTime(): void { + if (!this.isWorkingTimeValid()) return; + this.isLoading = true; + this.examManagementService.updateWorkingTime(this.exam.course!.id!, this.exam.id!, this.workingTimeSeconds).subscribe({ + next: (res: HttpResponse) => { + this.isLoading = false; + res.body && this.examChange.emit(res.body); + this.clear(); + }, + error: () => { + // If an error happens, the alert service takes care of displaying an error message + this.isLoading = false; + }, + }); + } + + isWorkingTimeValid(): boolean { + return Math.abs(this.workingTimeSeconds) !== 0; + } +} diff --git a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time.component.html b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time.component.html new file mode 100644 index 000000000000..d75cdb2ce745 --- /dev/null +++ b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time.component.html @@ -0,0 +1,4 @@ + diff --git a/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time.component.ts b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time.component.ts new file mode 100644 index 000000000000..ba39fe36298d --- /dev/null +++ b/src/main/webapp/app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time.component.ts @@ -0,0 +1,64 @@ +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { faHourglassHalf } from '@fortawesome/free-solid-svg-icons'; +import dayjs from 'dayjs/esm'; +import { Subscription, from } from 'rxjs'; + +import { Exam } from 'app/entities/exam.model'; +import { ExamEditWorkingTimeDialogComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time-dialog.component'; +import { AlertService } from 'app/core/util/alert.service'; + +@Component({ + selector: 'jhi-exam-edit-working-time', + templateUrl: './exam-edit-working-time.component.html', +}) +export class ExamEditWorkingTimeComponent implements OnInit, OnDestroy { + @Input() exam: Exam; + @Output() examChange = new EventEmitter(); + + faHourglassHalf = faHourglassHalf; + workingTimeChangeAllowed = false; + + private modalRef: NgbModalRef | null; + private timeoutRef: any; + private subscription: Subscription; + + constructor( + private modalService: NgbModal, + public alertService: AlertService, + ) {} + + ngOnInit() { + this.checkWorkingTimeChangeAllowed(); + } + + ngOnDestroy() { + this.timeoutRef && clearTimeout(this.timeoutRef); + this.subscription && this.subscription.unsubscribe(); + } + + private checkWorkingTimeChangeAllowed() { + const endDate = this.exam.endDate?.subtract(1, 'minutes'); + this.workingTimeChangeAllowed = dayjs().isBefore(endDate); + + // Run the check again when the exam ends + const nextCheckTimeout = endDate?.diff(); + if (nextCheckTimeout) { + this.timeoutRef = setTimeout(this.checkWorkingTimeChangeAllowed.bind(this), nextCheckTimeout); + } + } + + openDialog(event: MouseEvent) { + event.preventDefault(); + this.alertService.closeAll(); + this.modalRef = this.modalService.open(ExamEditWorkingTimeDialogComponent, { + size: 'lg', + backdrop: 'static', + animation: true, + }); + this.modalRef.componentInstance.exam = this.exam; + this.subscription = this.modalRef.componentInstance.examChange.subscribe((exam: Exam) => this.examChange.emit(exam)); + + from(this.modalRef.result).subscribe(() => (this.modalRef = null)); + } +} diff --git a/src/main/webapp/app/exam/shared/exam-shared.module.ts b/src/main/webapp/app/exam/shared/exam-shared.module.ts index f927fc8ef7cd..5ea06d463065 100644 --- a/src/main/webapp/app/exam/shared/exam-shared.module.ts +++ b/src/main/webapp/app/exam/shared/exam-shared.module.ts @@ -1,11 +1,12 @@ import { NgModule } from '@angular/core'; -import { StudentExamWorkingTimeComponent } from 'app/exam/shared/student-exam-working-time.component'; +import { StudentExamWorkingTimeComponent } from 'app/exam/shared/student-exam-working-time/student-exam-working-time.component'; import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; import { TestexamWorkingTimeComponent } from 'app/exam/shared/testExam-workingTime/testexam-working-time.component'; +import { WorkingTimeControlComponent } from 'app/exam/shared/working-time-update/working-time-control.component'; @NgModule({ imports: [ArtemisSharedCommonModule], - declarations: [StudentExamWorkingTimeComponent, TestexamWorkingTimeComponent], - exports: [StudentExamWorkingTimeComponent, TestexamWorkingTimeComponent], + declarations: [StudentExamWorkingTimeComponent, TestexamWorkingTimeComponent, WorkingTimeControlComponent], + exports: [StudentExamWorkingTimeComponent, TestexamWorkingTimeComponent, WorkingTimeControlComponent], }) export class ArtemisExamSharedModule {} diff --git a/src/main/webapp/app/exam/shared/student-exam-working-time.component.html b/src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.html similarity index 100% rename from src/main/webapp/app/exam/shared/student-exam-working-time.component.html rename to src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.html diff --git a/src/main/webapp/app/exam/shared/student-exam-working-time.component.ts b/src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.ts similarity index 100% rename from src/main/webapp/app/exam/shared/student-exam-working-time.component.ts rename to src/main/webapp/app/exam/shared/student-exam-working-time/student-exam-working-time.component.ts diff --git a/src/main/webapp/app/exam/shared/working-time-update/working-time-control.component.html b/src/main/webapp/app/exam/shared/working-time-update/working-time-control.component.html new file mode 100644 index 000000000000..e6c40825fc58 --- /dev/null +++ b/src/main/webapp/app/exam/shared/working-time-update/working-time-control.component.html @@ -0,0 +1,66 @@ +
    +
    + +
    + + + + + + +
    +
    +
    + +
    + + +
    +
    +
    diff --git a/src/main/webapp/app/exam/shared/working-time-update/working-time-control.component.scss b/src/main/webapp/app/exam/shared/working-time-update/working-time-control.component.scss new file mode 100644 index 000000000000..ac9b8c02301e --- /dev/null +++ b/src/main/webapp/app/exam/shared/working-time-update/working-time-control.component.scss @@ -0,0 +1,8 @@ +.working-time-control { + width: fit-content; +} + +.form-number-input { + max-width: 100px; + min-width: 64px; +} diff --git a/src/main/webapp/app/exam/shared/working-time-update/working-time-control.component.ts b/src/main/webapp/app/exam/shared/working-time-update/working-time-control.component.ts new file mode 100644 index 000000000000..90b3ce4c8a07 --- /dev/null +++ b/src/main/webapp/app/exam/shared/working-time-update/working-time-control.component.ts @@ -0,0 +1,161 @@ +import { Component, Input } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { Exam } from 'app/entities/exam.model'; +import { getRelativeWorkingTimeExtension, normalWorkingTime } from 'app/exam/participate/exam.utils'; +import { ArtemisDurationFromSecondsPipe } from 'app/shared/pipes/artemis-duration-from-seconds.pipe'; +import { round } from 'app/shared/util/utils'; + +@Component({ + selector: 'jhi-working-time-control', + templateUrl: './working-time-control.component.html', + styleUrls: ['./working-time-control.component.scss'], + providers: [ + ArtemisDurationFromSecondsPipe, + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: WorkingTimeControlComponent, + }, + ], +}) +export class WorkingTimeControlComponent implements ControlValueAccessor { + // Control disabled state + @Input() disabled = false; + @Input() showRelative = false; + + @Input() durationLabelText?: string; + @Input() relativeLabelText?: string; + + // The exam for which the working time should be updated + @Input() + set exam(exam: Exam | undefined) { + this.currentExam = exam; + this.initWorkingTimeFromCurrentExam(); + } + + get exam(): Exam | undefined { + return this.currentExam; + } + + private currentExam?: Exam; + private touched = false; + private onTouched = () => {}; + private onChange: (_: number) => void = () => {}; + + workingTime = { + hours: 0, + minutes: 0, + seconds: 0, + percent: 0, + }; + + constructor(private artemisDurationFromSecondsPipe: ArtemisDurationFromSecondsPipe) {} + + /** + * Updates the working time duration inputs whenever + * the value of the form control changes. + * @param seconds + */ + writeValue(seconds: number) { + if (seconds) { + this.setWorkingTimeDuration(seconds); + this.updateWorkingTimePercentFromDuration(); + } + } + + registerOnChange(onChange: any) { + this.onChange = onChange; + } + + registerOnTouched(onTouched: any) { + this.onTouched = onTouched; + } + + setDisabledState(disabled: boolean) { + this.disabled = disabled; + } + + private markAsTouched() { + if (!this.touched) { + this.onTouched(); + this.touched = true; + } + } + + /** + * Updates the controls based on the working time of the student exam. + */ + private initWorkingTimeFromCurrentExam() { + if (this.exam) { + this.setWorkingTimeDuration(normalWorkingTime(this.exam)!); + this.updateWorkingTimePercentFromDuration(); + this.onChange(this.getWorkingTimeSeconds()); + } + } + + /** + * Updates the working time duration values of the control whenever the percent value was changed. + * After the update, the onChange callback is called with the new working time in seconds. + */ + onPercentChanged() { + this.markAsTouched(); + this.updateWorkingTimeDurationFromPercent(); + this.onChange(this.getWorkingTimeSeconds()); + } + + /** + * Updates the working time percent value of the control whenever the duration values were changed. + * After the update, the onChange callback is called with the new working time in seconds. + */ + onDurationChanged() { + this.markAsTouched(); + this.updateWorkingTimePercentFromDuration(); + this.onChange(this.getWorkingTimeSeconds()); + } + + /** + * Updates the working time percent value of the control based on the current working time duration. + */ + private updateWorkingTimePercentFromDuration() { + if (this.exam) { + this.workingTime.percent = getRelativeWorkingTimeExtension(this.exam, this.getWorkingTimeSeconds()); + } + } + + /** + * Updates the working time duration values of the control based on the current working time percent. + */ + private updateWorkingTimeDurationFromPercent() { + if (this.exam) { + const regularWorkingTime = this.exam.workingTime!; + const absoluteWorkingTimeSeconds = round(regularWorkingTime * (1.0 + this.workingTime.percent / 100), 0); + console.log(regularWorkingTime, absoluteWorkingTimeSeconds); + this.setWorkingTimeDuration(absoluteWorkingTimeSeconds); + } + } + + /** + * Sets the working time duration values of the respective controls by + * converting the given seconds into hours, minutes and seconds. + * @param seconds the total number of seconds of working time. + */ + private setWorkingTimeDuration(seconds: number) { + const workingTime = this.artemisDurationFromSecondsPipe.secondsToDuration(seconds); + this.workingTime.hours = workingTime.days * 24 + workingTime.hours; + this.workingTime.minutes = workingTime.minutes; + this.workingTime.seconds = workingTime.seconds; + } + + /** + * Returns the seconds of the current working time duration. + */ + private getWorkingTimeSeconds(): number { + return this.artemisDurationFromSecondsPipe.durationToSeconds({ + days: 0, + hours: this.workingTime.hours, + minutes: this.workingTime.minutes, + seconds: this.workingTime.seconds, + }); + } +} diff --git a/src/main/webapp/app/shared/confirm-entity-name/confirm-entity-name.component.html b/src/main/webapp/app/shared/confirm-entity-name/confirm-entity-name.component.html new file mode 100644 index 000000000000..53dd681532e2 --- /dev/null +++ b/src/main/webapp/app/shared/confirm-entity-name/confirm-entity-name.component.html @@ -0,0 +1,4 @@ +
    + + +
    diff --git a/src/main/webapp/app/shared/confirm-entity-name/confirm-entity-name.component.scss b/src/main/webapp/app/shared/confirm-entity-name/confirm-entity-name.component.scss new file mode 100644 index 000000000000..435472461057 --- /dev/null +++ b/src/main/webapp/app/shared/confirm-entity-name/confirm-entity-name.component.scss @@ -0,0 +1,3 @@ +:host { + border: none !important; +} diff --git a/src/main/webapp/app/shared/confirm-entity-name/confirm-entity-name.component.ts b/src/main/webapp/app/shared/confirm-entity-name/confirm-entity-name.component.ts new file mode 100644 index 000000000000..92b768615a49 --- /dev/null +++ b/src/main/webapp/app/shared/confirm-entity-name/confirm-entity-name.component.ts @@ -0,0 +1,77 @@ +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator, Validators } from '@angular/forms'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'jhi-confirm-entity-name', + templateUrl: './confirm-entity-name.component.html', + styleUrls: ['./confirm-entity-name.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: ConfirmEntityNameComponent, + }, + { + provide: NG_VALIDATORS, + multi: true, + useExisting: ConfirmEntityNameComponent, + }, + ], +}) +export class ConfirmEntityNameComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator { + @Input() warningTextColor: string; + @Input() confirmationText: string; + @Input() entityName: string; + + control: FormControl; + + constructor(private fb: FormBuilder) {} + + onTouched = () => {}; + + private onChangeSubs: Subscription[] = []; + + ngOnInit() { + this.control = this.fb.control('', { + nonNullable: true, + validators: [Validators.required, Validators.pattern(this.entityName)], + }); + } + + ngOnDestroy() { + for (const sub of this.onChangeSubs) { + sub.unsubscribe(); + } + } + + writeValue(entityName: string) { + if (entityName) { + this.control.setValue(entityName, { emitEvent: false }); + } + } + + registerOnChange(onChange: (_: string) => void) { + this.onChangeSubs.push(this.control.valueChanges.subscribe(onChange)); + } + + registerOnTouched(fn: () => void) { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.control.disable(); + } else { + this.control.enable(); + } + } + + validate(): ValidationErrors | null { + if (this.control.valid) { + return null; + } + + return this.control.errors; + } +} diff --git a/src/main/webapp/app/shared/shared.module.ts b/src/main/webapp/app/shared/shared.module.ts index 108a76462502..b63a45c1c99a 100644 --- a/src/main/webapp/app/shared/shared.module.ts +++ b/src/main/webapp/app/shared/shared.module.ts @@ -23,6 +23,7 @@ import { AssessmentWarningComponent } from 'app/assessment/assessment-warning/as import { JhiConnectionWarningComponent } from 'app/shared/connection-warning/connection-warning.component'; import { LoadingIndicatorContainerComponent } from 'app/shared/loading-indicator-container/loading-indicator-container.component'; import { CompetencySelectionComponent } from 'app/shared/competency-selection/competency-selection.component'; +import { ConfirmEntityNameComponent } from 'app/shared/confirm-entity-name/confirm-entity-name.component'; @NgModule({ imports: [ArtemisSharedLibsModule, ArtemisSharedCommonModule, ArtemisSharedPipesModule, RouterModule], @@ -36,6 +37,7 @@ import { CompetencySelectionComponent } from 'app/shared/competency-selection/co SecuredImageComponent, DeleteButtonDirective, DeleteDialogComponent, + ConfirmEntityNameComponent, ResizeableContainerComponent, SecureLinkDirective, JhiConnectionStatusComponent, @@ -53,6 +55,7 @@ import { CompetencySelectionComponent } from 'app/shared/competency-selection/co ArtemisSharedCommonModule, ArtemisSharedPipesModule, CircularProgressBarComponent, + ConfirmEntityNameComponent, LoadingIndicatorContainerComponent, AdditionalFeedbackComponent, HasAnyAuthorityDirective, diff --git a/src/main/webapp/i18n/de/exam.json b/src/main/webapp/i18n/de/exam.json index b6dd4a97e237..d9d6979ee1dc 100644 --- a/src/main/webapp/i18n/de/exam.json +++ b/src/main/webapp/i18n/de/exam.json @@ -152,6 +152,10 @@ "problemStatementUpdate": { "showDiff": "Zeige Unterschiede", "showNew": "Zeige nur neue Version" + }, + "editWorkingTime": { + "absolute": "Absolut", + "relative": "Änderung relativ zur normalen Bearbeitungszeit" } }, "examScores": { @@ -569,6 +573,7 @@ "resolveComplaints": "Auflösung aller Beschwerden", "testRun": "Abhalten eines Testdurchlaufs", "exportResults": "Exportieren der Klausurergebnisse", + "editWorkingTime": "Bearbeiten der Prüfungsdauer", "suspiciousBehavior": "Überprüfung von verdächtigem Verhalten" }, "descriptionItem": { @@ -584,6 +589,7 @@ "resolveComplaints": "Beschwerden können im Bewertungs-Dashboard bearbeitet werden.", "testRun": "Probiere verschiedene Kombinationen von Übungsvarianten aus Sicht der Studierenden aus.", "exportResults": "Die Klausurergebnisse können in Form einer .csv Datei exportiert werden.", + "editWorkingTime": "Bis 5 Minuten vor dem Ende der Prüfung können Sie deren Dauer verlängern.", "suspiciousBehavior": "Verdächtiges Verhalten und Plagiatsfälle können in diesem Dashboard eingesehen werden." }, "textitems": { @@ -618,6 +624,13 @@ "variants": "Varianten" } }, + "editWorkingTime": { + "title": "Prüfungszeit bearbeiten", + "label": "Änderung der Prüfungszeit", + "duration": "Die neue Dauer der Prüfung beträgt {{ duration }}.", + "question": "Die Prüfungszeit wird für alle Studierenden angepasst. Wollen Sie die Prüfungszeit für die Klausur {{ title }} wirklich ändern?", + "typeNameToConfirm": "Bitte gib den Namen der Klausur zur Bestätigung ein." + }, "suspiciousBehavior": { "title": "Verdächtiges Verhalten", "examSessionDescription": "Bei jedem Eintritt in eine Klausur wird für den Studierenden eine Sitzung erstellt. Diese Sitzung ist die Kombination aus IP-Adresse, Sitzungsschlüssel, User Agent, Browser-Fingerabdruck, Browserinstanz-ID und einem dazugehörigen Zeitstempel.", diff --git a/src/main/webapp/i18n/en/exam.json b/src/main/webapp/i18n/en/exam.json index 68107a2d924c..6391635a9555 100644 --- a/src/main/webapp/i18n/en/exam.json +++ b/src/main/webapp/i18n/en/exam.json @@ -153,6 +153,10 @@ "problemStatementUpdate": { "showDiff": "Show Difference", "showNew": "Only show new Version" + }, + "editWorkingTime": { + "absolute": "Absolute", + "relative": "Change relative to regular working time" } }, "examScores": { @@ -571,6 +575,7 @@ "resolveComplaints": "Resolve all complaints", "testRun": "Conduct a test run", "exportResults": "Export the exam results", + "editWorkingTime": "Edit the exam duration", "suspiciousBehavior": "Check for suspicious behaviour" }, "descriptionItem": { @@ -586,6 +591,7 @@ "resolveComplaints": "Complaints can be resolved from the Assessment Dashboard.", "testRun": "Try out different combinations of exercise variants from the student perspective.", "exportResults": "Exports the exam results as a .csv file.", + "editWorkingTime": "Until five minutes before the end of the exam, you can extend its duration.", "suspiciousBehavior": "Check for suspicious behaviour and plagiarism in the suspicious behavior dashboard." }, "textitems": { @@ -620,6 +626,13 @@ "variants": "Variants" } }, + "editWorkingTime": { + "title": "Edit Exam Duration", + "label": "Exam duration change", + "duration": "The new duration of the exam will be {{ duration }}.", + "question": "This will change the working time for all students. Do you really want to edit the duration of the exam {{ title }}?", + "typeNameToConfirm": "Please type in the name of the exam to confirm." + }, "suspiciousBehavior": { "title": "Suspicious Behavior", "examSessionDescription": "Whenever a student enters a exam, a session is created for the student. This session is the combination of IP address, session token, user agent, browser fingerprint, browser instance ID and a timestamp.", diff --git a/src/test/java/de/tum/in/www1/artemis/exam/ExamUtilService.java b/src/test/java/de/tum/in/www1/artemis/exam/ExamUtilService.java index c40de1f9b855..1959b08bf8c1 100644 --- a/src/test/java/de/tum/in/www1/artemis/exam/ExamUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/exam/ExamUtilService.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import java.net.URI; -import java.time.Duration; import java.time.ZonedDateTime; import java.util.*; @@ -303,7 +302,7 @@ public Exam addExamWithUser(Course course, User user, boolean exerciseGroup, Zon exam.setVisibleDate(visibleDate); exam.setStartDate(startDate); exam.setEndDate(endDate); - exam.setWorkingTime((int) Duration.between(exam.getStartDate(), exam.getEndDate()).toSeconds()); + exam.setWorkingTime(exam.getDuration()); exam.setNumberOfCorrectionRoundsInExam(1); examRepository.save(exam); return exam; @@ -334,7 +333,7 @@ public Exam addExam(Course course, ZonedDateTime visibleDate, ZonedDateTime star exam.setVisibleDate(visibleDate); exam.setStartDate(startDate); exam.setEndDate(endDate); - exam.setWorkingTime((int) Duration.between(startDate, endDate).toSeconds()); + exam.setWorkingTime(exam.getDuration()); exam.setGracePeriod(180); exam = examRepository.save(exam); return exam; @@ -346,7 +345,7 @@ public Exam addExam(Course course, ZonedDateTime visibleDate, ZonedDateTime star exam.setStartDate(startDate); exam.setEndDate(endDate); exam.setPublishResultsDate(publishResultDate); - exam.setWorkingTime((int) Duration.between(startDate, endDate).toSeconds()); + exam.setWorkingTime(exam.getDuration()); exam.setGracePeriod(180); exam = examRepository.save(exam); return exam; @@ -375,7 +374,7 @@ public Exam addActiveExamWithRegisteredUser(Course course, User user) { studentExam.setExam(exam); studentExam.setTestRun(false); studentExam.setUser(user); - studentExam.setWorkingTime((int) Duration.between(exam.getStartDate(), exam.getEndDate()).toSeconds()); + studentExam.setWorkingTime(exam.getDuration()); studentExamRepository.save(studentExam); return exam; } @@ -440,7 +439,7 @@ public StudentExam addStudentExamWithUser(Exam exam, String user) { public StudentExam addStudentExamWithUser(Exam exam, User user) { StudentExam studentExam = ExamFactory.generateStudentExam(exam); studentExam.setUser(user); - studentExam.setWorkingTime((int) Duration.between(exam.getStartDate(), exam.getEndDate()).toSeconds()); + studentExam.setWorkingTime(exam.getDuration()); studentExam = studentExamRepository.save(studentExam); return studentExam; } @@ -492,7 +491,7 @@ public StudentExam addStudentExamForTestExam(Exam exam, User user) { public StudentExam addStudentExamWithUser(Exam exam, User user, int additionalWorkingTime) { StudentExam studentExam = ExamFactory.generateStudentExam(exam); studentExam.setUser(user); - studentExam.setWorkingTime((int) Duration.between(exam.getStartDate(), exam.getEndDate()).toSeconds() + additionalWorkingTime); + studentExam.setWorkingTime(exam.getDuration() + additionalWorkingTime); studentExam = studentExamRepository.save(studentExam); return studentExam; } @@ -703,7 +702,7 @@ public void setVisibleStartAndEndDateOfExam(Exam exam, ZonedDateTime visibleDate exam.setVisibleDate(visibleDate); exam.setStartDate(startDate); exam.setEndDate(endDate); - exam.setWorkingTime((int) Duration.between(startDate, endDate).toSeconds()); + exam.setWorkingTime(exam.getDuration()); } public StudentExam addExercisesWithParticipationsAndSubmissionsToStudentExam(Exam exam, StudentExam studentExam, String validModel, URI localRepoPath) { diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseScheduleServiceTest.java b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseScheduleServiceTest.java index cb75fbc64060..cdbb6775e78c 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseScheduleServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingExerciseScheduleServiceTest.java @@ -1,7 +1,14 @@ package de.tum.in.www1.artemis.exercise.programmingexercise; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; import java.net.URISyntaxException; import java.time.ZonedDateTime; @@ -26,10 +33,7 @@ import de.tum.in.www1.artemis.domain.ProgrammingExercise; import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.VcsRepositoryUrl; -import de.tum.in.www1.artemis.domain.enumeration.AssessmentType; -import de.tum.in.www1.artemis.domain.enumeration.ExerciseLifecycle; -import de.tum.in.www1.artemis.domain.enumeration.ParticipationLifecycle; -import de.tum.in.www1.artemis.domain.enumeration.Visibility; +import de.tum.in.www1.artemis.domain.enumeration.*; import de.tum.in.www1.artemis.domain.exam.Exam; import de.tum.in.www1.artemis.domain.exam.StudentExam; import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseParticipation; @@ -660,7 +664,7 @@ void testExamWorkingTimeChangeDuringConduction() { studentExam.setWorkingTime(1); studentExamRepository.save(studentExam); - instanceMessageReceiveService.processExamWorkingTimeChangeDuringConduction(studentExam.getId()); + instanceMessageReceiveService.processStudentExamIndividualWorkingTimeChangeDuringConduction(studentExam.getId()); verify(versionControlService, timeout(200)).setRepositoryPermissionsToReadOnly(participation.getVcsRepositoryUrl(), examExercise.getProjectKey(), participation.getStudents()); diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingSubmissionAndResultBitbucketBambooIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingSubmissionAndResultBitbucketBambooIntegrationTest.java index a25e6791ecfa..a22e5d96981b 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingSubmissionAndResultBitbucketBambooIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/programmingexercise/ProgrammingSubmissionAndResultBitbucketBambooIntegrationTest.java @@ -1,6 +1,7 @@ package de.tum.in.www1.artemis.exercise.programmingexercise; -import static de.tum.in.www1.artemis.config.Constants.*; +import static de.tum.in.www1.artemis.config.Constants.ASSIGNMENT_REPO_NAME; +import static de.tum.in.www1.artemis.config.Constants.NEW_RESULT_TOPIC; import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.C; import static de.tum.in.www1.artemis.domain.enumeration.ProgrammingLanguage.JAVA; import static de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingSubmissionConstants.*; @@ -8,7 +9,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; -import java.time.Duration; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.*; @@ -51,7 +51,6 @@ import de.tum.in.www1.artemis.exercise.ExerciseUtilService; import de.tum.in.www1.artemis.participation.ParticipationUtilService; import de.tum.in.www1.artemis.repository.*; -import de.tum.in.www1.artemis.service.connectors.bamboo.dto.BambooBuildLogDTO; import de.tum.in.www1.artemis.service.connectors.bamboo.dto.BambooBuildResultNotificationDTO; import de.tum.in.www1.artemis.service.connectors.bamboo.dto.BambooBuildResultNotificationDTO.BambooTestJobDTO; import de.tum.in.www1.artemis.service.exam.ExamDateService; @@ -682,8 +681,6 @@ void shouldSaveBuildLogsOnStudentParticipationWithoutResult(ProgrammingLanguage var submission = programmingExerciseUtilService.createProgrammingSubmission(participation, false); // Call programming-exercises/new-result which includes build log entries - final var buildLog = new BambooBuildLogDTO(ZonedDateTime.now().minusMinutes(1), "[ERROR] COMPILATION ERROR missing something", - "[ERROR] COMPILATION ERROR missing something"); postResultWithBuildLogs(participation.getBuildPlanId(), HttpStatus.OK, false, false); var result = assertBuildError(participation.getId(), userLogin, programmingLanguage); @@ -808,7 +805,7 @@ private StudentExam createEndedStudentExamWithGracePeriod(User user, Integer gra exam = examRepository.save(exam); var studentExam = studentExamRepository.findWithExercisesByUserIdAndExamId(user.getId(), exam.getId()).orElseThrow(); - studentExam.setWorkingTime((int) Duration.between(exam.getStartDate(), exam.getEndDate()).getSeconds()); + studentExam.setWorkingTime(exam.getDuration()); studentExam.setExercises(new ArrayList<>(exam.getExerciseGroups().get(6).getExercises())); studentExam.setUser(user); studentExam = studentExamRepository.save(studentExam); @@ -1017,7 +1014,7 @@ private Result assertBuildError(Long participationId, String userLogin, Programm // Assert that the build logs can be retrieved from the REST API userUtilService.changeUser(userLogin); - var receivedLogs = request.get("/api/repository/" + participationId + "/buildlogs", HttpStatus.OK, List.class); + var receivedLogs = request.getList("/api/repository/" + participationId + "/buildlogs", HttpStatus.OK, BuildLogEntry.class); assertThat(receivedLogs).isNotNull(); assertThat(receivedLogs).hasSameSizeAs(submissionWithLogs.getBuildLogEntries()); diff --git a/src/test/javascript/spec/component/exam/manage/exams/exam-checklist.component.spec.ts b/src/test/javascript/spec/component/exam/manage/exams/exam-checklist.component.spec.ts index 342fb0aabaff..c49036295352 100644 --- a/src/test/javascript/spec/component/exam/manage/exams/exam-checklist.component.spec.ts +++ b/src/test/javascript/spec/component/exam/manage/exams/exam-checklist.component.spec.ts @@ -13,6 +13,7 @@ import { MockExamChecklistService } from '../../../../helpers/mocks/service/mock import { of } from 'rxjs'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { MockWebsocketService } from '../../../../helpers/mocks/service/mock-websocket.service'; +import { ExamEditWorkingTimeComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time.component'; function getExerciseGroups(equalPoints: boolean) { const dueDateStatArray = [{ inTime: 0, late: 0, total: 0 }]; @@ -20,8 +21,20 @@ function getExerciseGroups(equalPoints: boolean) { { id: 1, exercises: [ - { id: 3, maxPoints: 100, numberOfAssessmentsOfCorrectionRounds: dueDateStatArray, studentAssignedTeamIdComputed: false, secondCorrectionEnabled: false }, - { id: 2, maxPoints: 100, numberOfAssessmentsOfCorrectionRounds: dueDateStatArray, studentAssignedTeamIdComputed: false, secondCorrectionEnabled: false }, + { + id: 3, + maxPoints: 100, + numberOfAssessmentsOfCorrectionRounds: dueDateStatArray, + studentAssignedTeamIdComputed: false, + secondCorrectionEnabled: false, + }, + { + id: 2, + maxPoints: 100, + numberOfAssessmentsOfCorrectionRounds: dueDateStatArray, + studentAssignedTeamIdComputed: false, + secondCorrectionEnabled: false, + }, ], }, ]; @@ -44,7 +57,14 @@ describe('ExamChecklistComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [ArtemisTestModule], - declarations: [ExamChecklistComponent, MockPipe(ArtemisDatePipe), MockDirective(TranslateDirective), ExamChecklistExerciseGroupTableComponent, ProgressBarComponent], + declarations: [ + ExamChecklistComponent, + MockPipe(ArtemisDatePipe), + MockDirective(TranslateDirective), + ExamChecklistExerciseGroupTableComponent, + ProgressBarComponent, + ExamEditWorkingTimeComponent, + ], providers: [ { provide: ExamChecklistService, useClass: MockExamChecklistService }, { provide: JhiWebsocketService, useClass: MockWebsocketService }, @@ -83,7 +103,15 @@ describe('ExamChecklistComponent', () => { const additionalExerciseGroup = { id: 13, - exercises: [{ id: 23, maxPoints: 100, numberOfAssessmentsOfCorrectionRounds: dueDateStatArray, studentAssignedTeamIdComputed: false, secondCorrectionEnabled: false }], + exercises: [ + { + id: 23, + maxPoints: 100, + numberOfAssessmentsOfCorrectionRounds: dueDateStatArray, + studentAssignedTeamIdComputed: false, + secondCorrectionEnabled: false, + }, + ], }; component.exam.exerciseGroups.push(additionalExerciseGroup); diff --git a/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts b/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts index 990602df500c..6ffbae67190c 100644 --- a/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts +++ b/src/test/javascript/spec/component/exam/manage/exams/exam-detail.component.spec.ts @@ -1,7 +1,7 @@ import { Location } from '@angular/common'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { Component } from '@angular/core'; -import { ComponentFixture, TestBed, fakeAsync } from '@angular/core/testing'; +import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ActivatedRoute, Data, Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; @@ -31,6 +31,7 @@ import { AlertService } from 'app/core/util/alert.service'; import { ArtemisDurationFromSecondsPipe } from 'app/shared/pipes/artemis-duration-from-seconds.pipe'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { MockWebsocketService } from '../../../../helpers/mocks/service/mock-websocket.service'; +import { ExamEditWorkingTimeComponent } from 'app/exam/manage/exams/exam-checklist-component/exam-edit-workingtime-dialog/exam-edit-working-time.component'; @Component({ template: '', @@ -52,7 +53,10 @@ describe('ExamDetailComponent', () => { RouterTestingModule.withRoutes([ { path: 'course-management/:courseId/exams/:examId/edit', component: DummyComponent }, { path: 'course-management/:courseId/exams/:examId/exercise-groups', component: DummyComponent }, - { path: 'course-management/:courseId/exams/:examId/assessment-dashboard', component: DummyComponent }, + { + path: 'course-management/:courseId/exams/:examId/assessment-dashboard', + component: DummyComponent, + }, { path: 'course-management/:courseId/exams/:examId/scores', component: DummyComponent }, { path: 'course-management/:courseId/exams/:examId/student-exams', component: DummyComponent }, { path: 'course-management/:courseId/exams/:examId/test-runs', component: DummyComponent }, @@ -77,6 +81,7 @@ describe('ExamDetailComponent', () => { MockDirective(DeleteButtonDirective), MockPipe(ArtemisDurationFromSecondsPipe), MockDirective(FeatureToggleLinkDirective), + ExamEditWorkingTimeComponent, ], providers: [ { @@ -142,6 +147,7 @@ describe('ExamDetailComponent', () => { examDetailComponentFixture.detectChanges(); const editButton = examDetailComponentFixture.debugElement.query(By.css('#editButton')).nativeElement; editButton.click(); + discardPeriodicTasks(); examDetailComponentFixture.whenStable().then(() => { expect(location.path()).toBe('/course-management/1/exams/1/edit'); }); @@ -152,6 +158,7 @@ describe('ExamDetailComponent', () => { examDetailComponentFixture.detectChanges(); const studentExamsButton = examDetailComponentFixture.debugElement.query(By.css('#studentExamsButton')).nativeElement; studentExamsButton.click(); + discardPeriodicTasks(); examDetailComponentFixture.whenStable().then(() => { expect(location.path()).toBe('/course-management/1/exams/1/student-exams'); }); @@ -162,6 +169,7 @@ describe('ExamDetailComponent', () => { examDetailComponentFixture.detectChanges(); const dashboardButton = examDetailComponentFixture.debugElement.query(By.css('#assessment-dashboard-button')).nativeElement; dashboardButton.click(); + discardPeriodicTasks(); examDetailComponentFixture.whenStable().then(() => { expect(location.path()).toBe('/course-management/1/exams/1/assessment-dashboard'); }); @@ -172,6 +180,7 @@ describe('ExamDetailComponent', () => { examDetailComponentFixture.detectChanges(); const dashboardButton = examDetailComponentFixture.debugElement.query(By.css('#exercises-button-groups')).nativeElement; dashboardButton.click(); + discardPeriodicTasks(); examDetailComponentFixture.whenStable().then(() => { expect(location.path()).toBe('/course-management/1/exams/1/exercise-groups'); }); @@ -182,6 +191,7 @@ describe('ExamDetailComponent', () => { examDetailComponentFixture.detectChanges(); const scoresButton = examDetailComponentFixture.debugElement.query(By.css('#scores-button')).nativeElement; scoresButton.click(); + discardPeriodicTasks(); examDetailComponentFixture.whenStable().then(() => { expect(location.path()).toBe('/course-management/1/exams/1/scores'); }); @@ -192,6 +202,7 @@ describe('ExamDetailComponent', () => { examDetailComponentFixture.detectChanges(); const studentsButton = examDetailComponentFixture.debugElement.query(By.css('#students-button')).nativeElement; studentsButton.click(); + discardPeriodicTasks(); examDetailComponentFixture.whenStable().then(() => { expect(location.path()).toBe('/course-management/1/exams/1/students'); }); @@ -202,6 +213,7 @@ describe('ExamDetailComponent', () => { examDetailComponentFixture.detectChanges(); const studentsButton = examDetailComponentFixture.debugElement.query(By.css('#testrun-button')).nativeElement; studentsButton.click(); + discardPeriodicTasks(); examDetailComponentFixture.whenStable().then(() => { expect(location.path()).toBe('/course-management/1/exams/1/test-runs'); }); diff --git a/src/test/javascript/spec/component/exam/manage/student-exams/student-exam-detail.component.spec.ts b/src/test/javascript/spec/component/exam/manage/student-exams/student-exam-detail.component.spec.ts index 7f80400eb658..945b2f6a70e2 100644 --- a/src/test/javascript/spec/component/exam/manage/student-exams/student-exam-detail.component.spec.ts +++ b/src/test/javascript/spec/component/exam/manage/student-exams/student-exam-detail.component.spec.ts @@ -30,7 +30,7 @@ import { DataTableComponent } from 'app/shared/data-table/data-table.component'; import { AlertService } from 'app/core/util/alert.service'; import { TranslateDirective } from 'app/shared/language/translate.directive'; import { MockTranslateValuesDirective } from '../../../../helpers/mocks/directive/mock-translate-values.directive'; -import { StudentExamWorkingTimeComponent } from 'app/exam/shared/student-exam-working-time.component'; +import { StudentExamWorkingTimeComponent } from 'app/exam/shared/student-exam-working-time/student-exam-working-time.component'; import { GradeType } from 'app/entities/grading-scale.model'; import { StudentExamWithGradeDTO } from 'app/exam/exam-scores/exam-score-dtos.model'; import { MockNgbModalService } from '../../../../helpers/mocks/service/mock-ngb-modal.service'; diff --git a/src/test/javascript/spec/component/exam/participate/information/exam-information.component.spec.ts b/src/test/javascript/spec/component/exam/participate/information/exam-information.component.spec.ts index 883c39eb35bf..c5dc07843146 100644 --- a/src/test/javascript/spec/component/exam/participate/information/exam-information.component.spec.ts +++ b/src/test/javascript/spec/component/exam/participate/information/exam-information.component.spec.ts @@ -4,7 +4,7 @@ import { User } from 'app/core/user/user.model'; import { Exam } from 'app/entities/exam.model'; import { StudentExam } from 'app/entities/student-exam.model'; import { ExamInformationComponent } from 'app/exam/participate/information/exam-information.component'; -import { StudentExamWorkingTimeComponent } from 'app/exam/shared/student-exam-working-time.component'; +import { StudentExamWorkingTimeComponent } from 'app/exam/shared/student-exam-working-time/student-exam-working-time.component'; import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; import { ArtemisDurationFromSecondsPipe } from 'app/shared/pipes/artemis-duration-from-seconds.pipe'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; diff --git a/src/test/javascript/spec/component/exam/shared/student-exam-working-time.component.spec.ts b/src/test/javascript/spec/component/exam/shared/student-exam-working-time.component.spec.ts index 4b84a57ebb73..1c71302e88cc 100644 --- a/src/test/javascript/spec/component/exam/shared/student-exam-working-time.component.spec.ts +++ b/src/test/javascript/spec/component/exam/shared/student-exam-working-time.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MockPipe } from 'ng-mocks'; import dayjs from 'dayjs/esm'; -import { StudentExamWorkingTimeComponent } from 'app/exam/shared/student-exam-working-time.component'; +import { StudentExamWorkingTimeComponent } from 'app/exam/shared/student-exam-working-time/student-exam-working-time.component'; import { ArtemisDurationFromSecondsPipe } from 'app/shared/pipes/artemis-duration-from-seconds.pipe'; import { Exam } from 'app/entities/exam.model'; import { StudentExam } from 'app/entities/student-exam.model';