From 4b9f308e5f1a5cc9ca0c0873de1d4cab19255a62 Mon Sep 17 00:00:00 2001 From: Matti Lupari Date: Thu, 25 Jan 2024 14:28:03 +0200 Subject: [PATCH] CSCEXAM-721 Add random delay to examination start moment --- app/controllers/EnrolmentController.java | 2 + .../CollaborativeEnrolmentController.java | 3 +- .../transfer/impl/ExternalExamController.java | 2 + app/models/ExamEnrolment.java | 17 +++++++++ app/repository/EnrolmentRepository.java | 13 +++++-- conf/evolutions/default/133.sql | 5 +++ ui/src/app/enrolment/enrolment.model.ts | 1 + .../waiting-room/waiting-room.component.html | 23 ++++++----- .../waiting-room/waiting-room.component.ts | 38 ++++++++++++++----- ui/src/assets/i18n/en.json | 3 +- ui/src/assets/i18n/fi.json | 3 +- ui/src/assets/i18n/sv.json | 3 +- 12 files changed, 87 insertions(+), 26 deletions(-) create mode 100644 conf/evolutions/default/133.sql diff --git a/app/controllers/EnrolmentController.java b/app/controllers/EnrolmentController.java index 7013c53e55..685345ab97 100644 --- a/app/controllers/EnrolmentController.java +++ b/app/controllers/EnrolmentController.java @@ -28,6 +28,7 @@ import java.util.Date; import java.util.List; import java.util.Optional; +import java.util.Random; import java.util.Set; import java.util.concurrent.CompletionStage; import java.util.concurrent.TimeUnit; @@ -173,6 +174,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/iop/collaboration/impl/CollaborativeEnrolmentController.java b/app/controllers/iop/collaboration/impl/CollaborativeEnrolmentController.java index 2256c0c92c..74291c7e16 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 e030ff65ad..dbcbf6e934 100644 --- a/app/controllers/iop/transfer/impl/ExternalExamController.java +++ b/app/controllers/iop/transfer/impl/ExternalExamController.java @@ -43,6 +43,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Random; import java.util.Set; import java.util.TreeSet; import java.util.UUID; @@ -403,6 +404,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 6ab0048ad4..a6ceff7640 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 f13d0498aa..72f6d0e5b9 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 0000000000..cd3c2cd1ea --- /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 34467c7836..d4b8a8e905 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/waiting-room/waiting-room.component.html b/ui/src/app/enrolment/waiting-room/waiting-room.component.html index be79b3f99a..8140d8c918 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 f3c2ba8b81..3d8ae71d3b 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, take } 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,24 @@ 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.round( + DateTime.fromJSDate(this.getStart()).plus({ seconds: this.enrolment.delay }).toSeconds() - + DateTime.now().toSeconds(), + ); + this.delayTimerId = window.setTimeout(this.Session.checkSession, offset); + this.delayCounter$ = interval(1000).pipe( + startWith(0), + take(offset), + 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 cdb6f44cc4..2e58a2bbee 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 f711b9ba1a..d043e4f597 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 fb763b1f8c..710a96174e 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" }