diff --git a/app/controllers/EnrolmentController.java b/app/controllers/EnrolmentController.java index 7013c53e5..689047b0e 100644 --- a/app/controllers/EnrolmentController.java +++ b/app/controllers/EnrolmentController.java @@ -173,6 +173,7 @@ private static ExamEnrolment makeEnrolment(Exam exam, User user) { enrolment.setUser(user); } enrolment.setExam(exam); + enrolment.setRandomDelay(); enrolment.save(); return enrolment; } diff --git a/app/controllers/TimeController.java b/app/controllers/TimeController.java index 331a4bf6f..eb0351984 100644 --- a/app/controllers/TimeController.java +++ b/app/controllers/TimeController.java @@ -60,7 +60,7 @@ public Result getRemainingExamTime(String hash, Http.Request request) throws IOE return notFound(); } - DateTime reservationStart = getStart(enrolment); + DateTime reservationStart = getStart(enrolment).plusSeconds(enrolment.getDelay()); int durationMinutes = getDuration(enrolment); DateTime now = getNow(enrolment); Seconds timeLeft = Seconds.secondsBetween(now, reservationStart.plusMinutes(durationMinutes)); diff --git a/app/controllers/iop/collaboration/impl/CollaborativeEnrolmentController.java b/app/controllers/iop/collaboration/impl/CollaborativeEnrolmentController.java index 2256c0c92..74291c7e1 100644 --- a/app/controllers/iop/collaboration/impl/CollaborativeEnrolmentController.java +++ b/app/controllers/iop/collaboration/impl/CollaborativeEnrolmentController.java @@ -135,6 +135,7 @@ private static ExamEnrolment makeEnrolment(CollaborativeExam exam, User user) { enrolment.setEnrolledOn(DateTime.now()); enrolment.setUser(user); enrolment.setCollaborativeExam(exam); + enrolment.setRandomDelay(); enrolment.save(); return enrolment; } @@ -155,7 +156,7 @@ private Optional handleFutureReservations(List enrolments } // reservation in the future, replace it if (!enrolmentsWithFutureReservations.isEmpty()) { - enrolmentsWithFutureReservations.get(0).delete(); + enrolmentsWithFutureReservations.getFirst().delete(); ExamEnrolment newEnrolment = makeEnrolment(ce, user); return Optional.of(ok(newEnrolment)); } diff --git a/app/controllers/iop/transfer/impl/ExternalExamController.java b/app/controllers/iop/transfer/impl/ExternalExamController.java index e030ff65a..6c35380e3 100644 --- a/app/controllers/iop/transfer/impl/ExternalExamController.java +++ b/app/controllers/iop/transfer/impl/ExternalExamController.java @@ -403,6 +403,7 @@ public CompletionStage requestEnrolment(User user, Reservation re enrolment.setExternalExam(ee); enrolment.setReservation(reservation); enrolment.setUser(user); + enrolment.setRandomDelay(); enrolment.save(); return enrolment; }; diff --git a/app/models/ExamEnrolment.java b/app/models/ExamEnrolment.java index 6ab0048ad..a6ceff764 100644 --- a/app/models/ExamEnrolment.java +++ b/app/models/ExamEnrolment.java @@ -27,6 +27,7 @@ import jakarta.persistence.OneToOne; import jakarta.persistence.Temporal; import jakarta.persistence.TemporalType; +import java.util.Random; import java.util.Set; import javax.annotation.Nonnull; import models.base.GeneratedIdentityModel; @@ -41,6 +42,8 @@ @Entity public class ExamEnrolment extends GeneratedIdentityModel implements Comparable { + private static final int DELAY_MAX = 30; + @ManyToOne @JsonManagedReference private User user; @@ -85,6 +88,8 @@ public class ExamEnrolment extends GeneratedIdentityModel implements Comparable< private boolean retrialPermitted; + private int delay; + public User getUser() { return user; } @@ -189,10 +194,22 @@ public void setRetrialPermitted(boolean retrialPermitted) { this.retrialPermitted = retrialPermitted; } + public int getDelay() { + return delay; + } + + public void setDelay(int delay) { + this.delay = delay; + } + public boolean isProcessed() { return (exam != null && exam.hasState(Exam.State.GRADED_LOGGED, Exam.State.ARCHIVED, Exam.State.DELETED)); } + public void setRandomDelay() { + this.setDelay(new Random().nextInt(DELAY_MAX)); + } + @Override public int compareTo(@Nonnull ExamEnrolment other) { if (reservation == null && other.reservation == null) { diff --git a/app/repository/EnrolmentRepository.java b/app/repository/EnrolmentRepository.java index f13d0498a..72f6d0e5b 100644 --- a/app/repository/EnrolmentRepository.java +++ b/app/repository/EnrolmentRepository.java @@ -319,20 +319,25 @@ private boolean isInsideBounds(ExamEnrolment ee, int minutesToFuture) { ExaminationEvent event = ee.getExaminationEventConfiguration() != null ? ee.getExaminationEventConfiguration().getExaminationEvent() : null; + int delay = ee.getDelay(); return ( (reservation != null && - reservation.getStartAt().isBefore(latest) && + reservation.getStartAt().plusSeconds(delay).isBefore(latest) && reservation.getEndAt().isAfter(earliest)) || (event != null && - event.getStart().isBefore(latest) && + event.getStart().plusSeconds(delay).isBefore(latest) && event.getStart().plusMinutes(ee.getExam().getDuration()).isAfter(earliest)) ); } private DateTime getStartTime(ExamEnrolment enrolment) { return enrolment.getReservation() != null - ? enrolment.getReservation().getStartAt() - : enrolment.getExaminationEventConfiguration().getExaminationEvent().getStart(); + ? enrolment.getReservation().getStartAt().plusSeconds(enrolment.getDelay()) + : enrolment + .getExaminationEventConfiguration() + .getExaminationEvent() + .getStart() + .plusSeconds(enrolment.getDelay()); } private Optional getNextEnrolment(Long userId, int minutesToFuture) { diff --git a/conf/evolutions/default/133.sql b/conf/evolutions/default/133.sql new file mode 100644 index 000000000..cd3c2cd1e --- /dev/null +++ b/conf/evolutions/default/133.sql @@ -0,0 +1,5 @@ +# --- !Ups +ALTER TABLE exam_enrolment ADD delay INT NOT NULL DEFAULT 0; + +# --- !Downs +ALTER TABLE exam_enrolment DROP delay; diff --git a/ui/src/app/enrolment/enrolment.model.ts b/ui/src/app/enrolment/enrolment.model.ts index 34467c783..d4b8a8e90 100644 --- a/ui/src/app/enrolment/enrolment.model.ts +++ b/ui/src/app/enrolment/enrolment.model.ts @@ -47,6 +47,7 @@ export interface ExamEnrolment { noShow: boolean; retrialPermitted: boolean; optionalSections: ExamSection[]; + delay: number; } export interface EnrolmentInfo extends Exam { diff --git a/ui/src/app/enrolment/search/exam-search.component.ts b/ui/src/app/enrolment/search/exam-search.component.ts index f68b8d9af..d0ca7cef8 100644 --- a/ui/src/app/enrolment/search/exam-search.component.ts +++ b/ui/src/app/enrolment/search/exam-search.component.ts @@ -112,7 +112,7 @@ import { ExamSearchService } from './exam-search.service'; }) export class ExamSearchComponent implements OnInit, OnDestroy { exams: EnrolmentInfo[] = []; - filterChanged: Subject = new Subject(); + filterChanged = new Subject(); ngUnsubscribe = new Subject(); filter = { text: '' }; permissionCheck = { active: false }; diff --git a/ui/src/app/enrolment/waiting-room/waiting-room.component.html b/ui/src/app/enrolment/waiting-room/waiting-room.component.html index be79b3f99..aba0e9c4a 100644 --- a/ui/src/app/enrolment/waiting-room/waiting-room.component.html +++ b/ui/src/app/enrolment/waiting-room/waiting-room.component.html @@ -7,13 +7,20 @@

-
- - {{ 'i18n_redirect_to_exam_notice' | translate }} -
+ @if (delayCounter$) { +
+ + {{ delayCounter$ | async }} {{ 'i18n_seconds_until_examination_starts' | translate }} +
+ } @else if (isUpcoming()) { +
+ + {{ 'i18n_redirect_to_exam_notice' | translate }} +
+ }
-@if (isUpcoming) { +@if (isUpcoming()) {
@@ -104,16 +111,14 @@

} - @if (roomInstructions) { + @if (roomInstructions()) {
- } - @if (roomInstructions) {
-
{{ roomInstructions }}
+
{{ roomInstructions() }}
} @if (enrolment?.exam?.enrollInstruction) { diff --git a/ui/src/app/enrolment/waiting-room/waiting-room.component.ts b/ui/src/app/enrolment/waiting-room/waiting-room.component.ts index f3c2ba8b8..d9cae3cc0 100644 --- a/ui/src/app/enrolment/waiting-room/waiting-room.component.ts +++ b/ui/src/app/enrolment/waiting-room/waiting-room.component.ts @@ -12,10 +12,10 @@ * on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the Licence for the specific language governing permissions and limitations under the Licence. */ -import { DatePipe, SlicePipe, UpperCasePipe } from '@angular/common'; +import { AsyncPipe, DatePipe, SlicePipe, UpperCasePipe } from '@angular/common'; import { HttpClient } from '@angular/common/http'; import type { OnDestroy, OnInit } from '@angular/core'; -import { Component } from '@angular/core'; +import { Component, signal } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { DateTime } from 'luxon'; @@ -28,6 +28,7 @@ import { MathJaxDirective } from '../../shared/math/math-jax.directive'; import { CourseCodeComponent } from '../../shared/miscellaneous/course-code.component'; import { TeacherListComponent } from '../../shared/user/teacher-list.component'; import type { ExamEnrolment } from '../enrolment.model'; +import { Observable, interval, map, startWith } from 'rxjs'; type WaitingReservation = Reservation & { occasion: { startAt: string; endAt: string } }; type WaitingEnrolment = Omit & { @@ -42,6 +43,7 @@ type WaitingEnrolment = Omit & { CourseCodeComponent, TeacherListComponent, MathJaxDirective, + AsyncPipe, UpperCasePipe, SlicePipe, DatePipe, @@ -51,9 +53,12 @@ type WaitingEnrolment = Omit & { }) export class WaitingRoomComponent implements OnInit, OnDestroy { enrolment!: WaitingEnrolment; - isUpcoming = false; - timeoutId = 0; - roomInstructions = ''; + isUpcoming = signal(false); + roomInstructions = signal(''); + delayCounter$?: Observable; + + private startTimerId = 0; + private delayTimerId = 0; constructor( private http: HttpClient, @@ -66,17 +71,17 @@ export class WaitingRoomComponent implements OnInit, OnDestroy { ngOnInit() { if (this.route.snapshot.params.id && this.route.snapshot.params.hash) { - this.isUpcoming = true; + this.isUpcoming.set(true); this.http.get(`/app/student/enrolments/${this.route.snapshot.params.id}`).subscribe({ next: (enrolment) => { this.setOccasion(enrolment.reservation); this.enrolment = enrolment; const offset = this.calculateOffset(); - this.timeoutId = window.setTimeout(this.Session.checkSession, offset); + this.startTimerId = window.setTimeout(this.startScheduled, offset); if (this.enrolment.reservation) { const room = this.enrolment.reservation.machine.room; const code = this.translate.currentLang.toUpperCase(); - this.roomInstructions = this.getRoomInstructions(code, room); + this.roomInstructions.set(this.getRoomInstructions(code, room)); } this.http .post(`/app/student/exam/${this.route.snapshot.params.hash}`, {}) @@ -88,9 +93,23 @@ export class WaitingRoomComponent implements OnInit, OnDestroy { } ngOnDestroy() { - window.clearTimeout(this.timeoutId); + window.clearTimeout(this.startTimerId); + window.clearTimeout(this.delayTimerId); } + private startScheduled = () => { + window.clearTimeout(this.startTimerId); + const offset = Math.ceil( + DateTime.fromJSDate(this.getStart()).plus({ seconds: this.enrolment.delay }).toSeconds() - + DateTime.now().toSeconds(), + ); + this.delayTimerId = window.setTimeout(this.Session.checkSession, offset * 1000); + this.delayCounter$ = interval(1000).pipe( + startWith(0), + map((n) => offset - n), + ); + }; + private getRoomInstructions = (lang: string, room: ExamRoom) => { switch (lang) { case 'FI': diff --git a/ui/src/assets/i18n/en.json b/ui/src/assets/i18n/en.json index cdb6f44cc..2e58a2bbe 100644 --- a/ui/src/assets/i18n/en.json +++ b/ui/src/assets/i18n/en.json @@ -1159,5 +1159,6 @@ "i18n_button_preview": "Preview question", "i18n_no_preview_available": "No preview available", "i18n_used_in_exams": "Used in exams", - "i18n_quit_password": "SEB-poistumissalasana EN" + "i18n_quit_password": "SEB-poistumissalasana EN", + "i18n_seconds_until_examination_starts": "sekuntia tentin käynnistymiseen EN" } diff --git a/ui/src/assets/i18n/fi.json b/ui/src/assets/i18n/fi.json index f711b9ba1..d043e4f59 100644 --- a/ui/src/assets/i18n/fi.json +++ b/ui/src/assets/i18n/fi.json @@ -1159,5 +1159,6 @@ "i18n_button_preview": "Esikatsele kysymys", "i18n_no_preview_available": "Kysymyksen esikatselu ei ole saatavilla", "i18n_used_in_exams": "Käytössä tenteissä", - "i18n_quit_password": "SEB-poistumissalasana" + "i18n_quit_password": "SEB-poistumissalasana", + "i18n_seconds_until_examination_starts": "sekuntia tentin käynnistymiseen" } diff --git a/ui/src/assets/i18n/sv.json b/ui/src/assets/i18n/sv.json index fb763b1f8..710a96174 100644 --- a/ui/src/assets/i18n/sv.json +++ b/ui/src/assets/i18n/sv.json @@ -1159,5 +1159,6 @@ "i18n_button_preview": "Preview question SV", "i18n_no_preview_available": "No preview available SV", "i18n_used_in_exams": "Käytössä tenteissä SV", - "i18n_quit_password": "SEB-poistumissalasana SV" + "i18n_quit_password": "SEB-poistumissalasana SV", + "i18n_seconds_until_examination_starts": "sekuntia tentin käynnistymiseen SV" }