diff --git a/app/controllers/assessment/ExamAnswerController.java b/app/controllers/assessment/ExamAnswerController.java index e2b898c50..4d14f5b74 100644 --- a/app/controllers/assessment/ExamAnswerController.java +++ b/app/controllers/assessment/ExamAnswerController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/assessment/ReviewDocumentsController.scala b/app/controllers/assessment/ReviewDocumentsController.scala index 19359df4d..321e39c80 100644 --- a/app/controllers/assessment/ReviewDocumentsController.scala +++ b/app/controllers/assessment/ReviewDocumentsController.scala @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/assets/FrontendController.scala b/app/controllers/assets/FrontendController.scala index d50cabf26..5b47f9661 100644 --- a/app/controllers/assets/FrontendController.scala +++ b/app/controllers/assets/FrontendController.scala @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/base/SectionQuestionHandler.java b/app/controllers/base/SectionQuestionHandler.java index 68d86147d..fbd3d85a4 100644 --- a/app/controllers/base/SectionQuestionHandler.java +++ b/app/controllers/base/SectionQuestionHandler.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/exam/CourseController.scala b/app/controllers/exam/CourseController.scala index 0fe8ea0bc..e253ce2e7 100644 --- a/app/controllers/exam/CourseController.scala +++ b/app/controllers/exam/CourseController.scala @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/exam/ExamController.java b/app/controllers/exam/ExamController.java index 8867db185..d3323e85e 100644 --- a/app/controllers/exam/ExamController.java +++ b/app/controllers/exam/ExamController.java @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/facility/RoomLike.java b/app/controllers/facility/RoomLike.java index 6062dda28..fb25d61de 100644 --- a/app/controllers/facility/RoomLike.java +++ b/app/controllers/facility/RoomLike.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/integration/ReportAPIController.java b/app/controllers/integration/ReportAPIController.java index cc587a991..429ef1802 100644 --- a/app/controllers/integration/ReportAPIController.java +++ b/app/controllers/integration/ReportAPIController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/collaboration/api/CollaborativeAttachmentInterface.java b/app/controllers/iop/collaboration/api/CollaborativeAttachmentInterface.java index bfedea6b1..676709618 100644 --- a/app/controllers/iop/collaboration/api/CollaborativeAttachmentInterface.java +++ b/app/controllers/iop/collaboration/api/CollaborativeAttachmentInterface.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/collaboration/api/CollaborativeExamLoader.java b/app/controllers/iop/collaboration/api/CollaborativeExamLoader.java index 054fb939e..bf5c5c9e5 100644 --- a/app/controllers/iop/collaboration/api/CollaborativeExamLoader.java +++ b/app/controllers/iop/collaboration/api/CollaborativeExamLoader.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/collaboration/impl/CollaborationController.java b/app/controllers/iop/collaboration/impl/CollaborationController.java index c6ec9c6ce..3cdad3adb 100644 --- a/app/controllers/iop/collaboration/impl/CollaborationController.java +++ b/app/controllers/iop/collaboration/impl/CollaborationController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/collaboration/impl/CollaborativeAttachmentController.java b/app/controllers/iop/collaboration/impl/CollaborativeAttachmentController.java index cb15e5557..932e8942a 100644 --- a/app/controllers/iop/collaboration/impl/CollaborativeAttachmentController.java +++ b/app/controllers/iop/collaboration/impl/CollaborativeAttachmentController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/collaboration/impl/CollaborativeCalendarController.java b/app/controllers/iop/collaboration/impl/CollaborativeCalendarController.java index cddfec351..eb336000e 100644 --- a/app/controllers/iop/collaboration/impl/CollaborativeCalendarController.java +++ b/app/controllers/iop/collaboration/impl/CollaborativeCalendarController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/collaboration/impl/CollaborativeEnrolmentController.java b/app/controllers/iop/collaboration/impl/CollaborativeEnrolmentController.java index 561bf990a..a16d08d19 100644 --- a/app/controllers/iop/collaboration/impl/CollaborativeEnrolmentController.java +++ b/app/controllers/iop/collaboration/impl/CollaborativeEnrolmentController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/collaboration/impl/CollaborativeExamLoaderImpl.java b/app/controllers/iop/collaboration/impl/CollaborativeExamLoaderImpl.java index 7415e8687..e452e7eaa 100644 --- a/app/controllers/iop/collaboration/impl/CollaborativeExamLoaderImpl.java +++ b/app/controllers/iop/collaboration/impl/CollaborativeExamLoaderImpl.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/collaboration/impl/CollaborativeExamSectionController.java b/app/controllers/iop/collaboration/impl/CollaborativeExamSectionController.java index b06606aef..f50baf977 100644 --- a/app/controllers/iop/collaboration/impl/CollaborativeExamSectionController.java +++ b/app/controllers/iop/collaboration/impl/CollaborativeExamSectionController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/collaboration/impl/CollaborativeExternalCalendarController.java b/app/controllers/iop/collaboration/impl/CollaborativeExternalCalendarController.java index f41e941ed..279e0ea63 100644 --- a/app/controllers/iop/collaboration/impl/CollaborativeExternalCalendarController.java +++ b/app/controllers/iop/collaboration/impl/CollaborativeExternalCalendarController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/collaboration/impl/CollaborativeReviewController.java b/app/controllers/iop/collaboration/impl/CollaborativeReviewController.java index 7531d4e3c..299211816 100644 --- a/app/controllers/iop/collaboration/impl/CollaborativeReviewController.java +++ b/app/controllers/iop/collaboration/impl/CollaborativeReviewController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/collaboration/impl/CollaborativeStudentActionController.java b/app/controllers/iop/collaboration/impl/CollaborativeStudentActionController.java index eab7809c8..5a45e07e1 100644 --- a/app/controllers/iop/collaboration/impl/CollaborativeStudentActionController.java +++ b/app/controllers/iop/collaboration/impl/CollaborativeStudentActionController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/transfer/api/ExternalAttachmentInterface.java b/app/controllers/iop/transfer/api/ExternalAttachmentInterface.java index c99ce63ae..543e8168c 100644 --- a/app/controllers/iop/transfer/api/ExternalAttachmentInterface.java +++ b/app/controllers/iop/transfer/api/ExternalAttachmentInterface.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/transfer/api/ExternalAttachmentLoader.java b/app/controllers/iop/transfer/api/ExternalAttachmentLoader.java index e87d26245..90382f3bb 100644 --- a/app/controllers/iop/transfer/api/ExternalAttachmentLoader.java +++ b/app/controllers/iop/transfer/api/ExternalAttachmentLoader.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/transfer/api/ExternalExamAPI.java b/app/controllers/iop/transfer/api/ExternalExamAPI.java index 89bfbeab2..30d6c2e30 100644 --- a/app/controllers/iop/transfer/api/ExternalExamAPI.java +++ b/app/controllers/iop/transfer/api/ExternalExamAPI.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/transfer/api/ExternalFacilityAPI.java b/app/controllers/iop/transfer/api/ExternalFacilityAPI.java index b4489f302..804b9e075 100644 --- a/app/controllers/iop/transfer/api/ExternalFacilityAPI.java +++ b/app/controllers/iop/transfer/api/ExternalFacilityAPI.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/transfer/api/ExternalReservationHandler.java b/app/controllers/iop/transfer/api/ExternalReservationHandler.java index 90584354d..e0bbddf5b 100644 --- a/app/controllers/iop/transfer/api/ExternalReservationHandler.java +++ b/app/controllers/iop/transfer/api/ExternalReservationHandler.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/transfer/impl/DataTransferController.java b/app/controllers/iop/transfer/impl/DataTransferController.java index 51c024b9c..0850475e1 100644 --- a/app/controllers/iop/transfer/impl/DataTransferController.java +++ b/app/controllers/iop/transfer/impl/DataTransferController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/transfer/impl/ExternalAttachmentController.java b/app/controllers/iop/transfer/impl/ExternalAttachmentController.java index 85d40ef20..e6ebf10d9 100644 --- a/app/controllers/iop/transfer/impl/ExternalAttachmentController.java +++ b/app/controllers/iop/transfer/impl/ExternalAttachmentController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/transfer/impl/ExternalAttachmentLoaderImpl.java b/app/controllers/iop/transfer/impl/ExternalAttachmentLoaderImpl.java index 4037e8fbc..1ca5720b7 100644 --- a/app/controllers/iop/transfer/impl/ExternalAttachmentLoaderImpl.java +++ b/app/controllers/iop/transfer/impl/ExternalAttachmentLoaderImpl.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/transfer/impl/ExternalCalendarController.java b/app/controllers/iop/transfer/impl/ExternalCalendarController.java index 3fad72380..28fef4dce 100644 --- a/app/controllers/iop/transfer/impl/ExternalCalendarController.java +++ b/app/controllers/iop/transfer/impl/ExternalCalendarController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/transfer/impl/ExternalExaminationController.java b/app/controllers/iop/transfer/impl/ExternalExaminationController.java index b7386629d..ffd5f71cc 100644 --- a/app/controllers/iop/transfer/impl/ExternalExaminationController.java +++ b/app/controllers/iop/transfer/impl/ExternalExaminationController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/transfer/impl/ExternalReservationHandlerImpl.java b/app/controllers/iop/transfer/impl/ExternalReservationHandlerImpl.java index aaa2df67e..0d564d77b 100644 --- a/app/controllers/iop/transfer/impl/ExternalReservationHandlerImpl.java +++ b/app/controllers/iop/transfer/impl/ExternalReservationHandlerImpl.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/transfer/impl/FacilityController.java b/app/controllers/iop/transfer/impl/FacilityController.java index 98d9965fa..3ed4f6300 100644 --- a/app/controllers/iop/transfer/impl/FacilityController.java +++ b/app/controllers/iop/transfer/impl/FacilityController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/controllers/iop/transfer/impl/OrganisationController.java b/app/controllers/iop/transfer/impl/OrganisationController.java index 1f8d6bbc9..d2d471df6 100644 --- a/app/controllers/iop/transfer/impl/OrganisationController.java +++ b/app/controllers/iop/transfer/impl/OrganisationController.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/impl/CalendarHandler.java b/app/impl/CalendarHandler.java index ee8a168b0..fc47ce3e4 100644 --- a/app/impl/CalendarHandler.java +++ b/app/impl/CalendarHandler.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/miscellaneous/config/ByodConfigHandler.scala b/app/miscellaneous/config/ByodConfigHandler.scala index 727ba51a6..991c896a4 100644 --- a/app/miscellaneous/config/ByodConfigHandler.scala +++ b/app/miscellaneous/config/ByodConfigHandler.scala @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/miscellaneous/config/ByodConfigHandlerImpl.scala b/app/miscellaneous/config/ByodConfigHandlerImpl.scala index bf0499c2c..40cb31418 100644 --- a/app/miscellaneous/config/ByodConfigHandlerImpl.scala +++ b/app/miscellaneous/config/ByodConfigHandlerImpl.scala @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/miscellaneous/csv/CsvBuilder.java b/app/miscellaneous/csv/CsvBuilder.java index 345c4075f..9bdbec57a 100644 --- a/app/miscellaneous/csv/CsvBuilder.java +++ b/app/miscellaneous/csv/CsvBuilder.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/miscellaneous/excel/ExcelBuilderImpl.java b/app/miscellaneous/excel/ExcelBuilderImpl.java index 89916eec7..db3ddf2dd 100644 --- a/app/miscellaneous/excel/ExcelBuilderImpl.java +++ b/app/miscellaneous/excel/ExcelBuilderImpl.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/miscellaneous/file/FileHandler.java b/app/miscellaneous/file/FileHandler.java index 5fc6c81b5..c52c795b8 100644 --- a/app/miscellaneous/file/FileHandler.java +++ b/app/miscellaneous/file/FileHandler.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/miscellaneous/file/FileHandlerImpl.java b/app/miscellaneous/file/FileHandlerImpl.java index 200ca6198..53c7dfe04 100644 --- a/app/miscellaneous/file/FileHandlerImpl.java +++ b/app/miscellaneous/file/FileHandlerImpl.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/miscellaneous/xml/MoodleXmlExporter.scala b/app/miscellaneous/xml/MoodleXmlExporter.scala index 0fb96004f..9ff8b7a68 100644 --- a/app/miscellaneous/xml/MoodleXmlExporter.scala +++ b/app/miscellaneous/xml/MoodleXmlExporter.scala @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/miscellaneous/xml/MoodleXmlExporterImpl.scala b/app/miscellaneous/xml/MoodleXmlExporterImpl.scala index fa4a1c240..f574c7a98 100644 --- a/app/miscellaneous/xml/MoodleXmlExporterImpl.scala +++ b/app/miscellaneous/xml/MoodleXmlExporterImpl.scala @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/miscellaneous/xml/MoodleXmlImporter.scala b/app/miscellaneous/xml/MoodleXmlImporter.scala index 8700c50f7..a28a01c85 100644 --- a/app/miscellaneous/xml/MoodleXmlImporter.scala +++ b/app/miscellaneous/xml/MoodleXmlImporter.scala @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/miscellaneous/xml/MoodleXmlImporterImpl.scala b/app/miscellaneous/xml/MoodleXmlImporterImpl.scala index 80f97cd9c..2e0bdd7e0 100644 --- a/app/miscellaneous/xml/MoodleXmlImporterImpl.scala +++ b/app/miscellaneous/xml/MoodleXmlImporterImpl.scala @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/models/assessment/Comment.java b/app/models/assessment/Comment.java index f8a82499a..2e0683232 100644 --- a/app/models/assessment/Comment.java +++ b/app/models/assessment/Comment.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/models/calendar/MaintenancePeriod.java b/app/models/calendar/MaintenancePeriod.java index a768ceabb..e79bfaadc 100644 --- a/app/models/calendar/MaintenancePeriod.java +++ b/app/models/calendar/MaintenancePeriod.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/models/sections/ExamMaterial.java b/app/models/sections/ExamMaterial.java index f29edeb38..d8e5d17d9 100644 --- a/app/models/sections/ExamMaterial.java +++ b/app/models/sections/ExamMaterial.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/models/sections/ExamSection.java b/app/models/sections/ExamSection.java index 53c4e486c..372235e24 100644 --- a/app/models/sections/ExamSection.java +++ b/app/models/sections/ExamSection.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/models/sections/ExamSectionQuestion.java b/app/models/sections/ExamSectionQuestion.java index 4d719829f..75ed67649 100644 --- a/app/models/sections/ExamSectionQuestion.java +++ b/app/models/sections/ExamSectionQuestion.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/models/sections/ExamSectionQuestionOption.java b/app/models/sections/ExamSectionQuestionOption.java index eccc3290b..659edd206 100644 --- a/app/models/sections/ExamSectionQuestionOption.java +++ b/app/models/sections/ExamSectionQuestionOption.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/repository/DatabaseExecutionContext.java b/app/repository/DatabaseExecutionContext.java index f8c9c0c91..c4127ee58 100644 --- a/app/repository/DatabaseExecutionContext.java +++ b/app/repository/DatabaseExecutionContext.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/repository/EnrolmentRepository.java b/app/repository/EnrolmentRepository.java index 1e160bb8e..12af1c4f0 100644 --- a/app/repository/EnrolmentRepository.java +++ b/app/repository/EnrolmentRepository.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/repository/ExaminationRepository.java b/app/repository/ExaminationRepository.java index a68cb17df..1ecdceaf3 100644 --- a/app/repository/ExaminationRepository.java +++ b/app/repository/ExaminationRepository.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/repository/UserRepository.java b/app/repository/UserRepository.java index 54afc40c5..8c99626cc 100644 --- a/app/repository/UserRepository.java +++ b/app/repository/UserRepository.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/sanitizers/ExternalRefCollectionSanitizer.java b/app/sanitizers/ExternalRefCollectionSanitizer.java index bc15324b8..16edacde4 100644 --- a/app/sanitizers/ExternalRefCollectionSanitizer.java +++ b/app/sanitizers/ExternalRefCollectionSanitizer.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/security/Authenticated.java b/app/security/Authenticated.java index 7c783b163..829672085 100644 --- a/app/security/Authenticated.java +++ b/app/security/Authenticated.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/security/scala/Auth.scala b/app/security/scala/Auth.scala index 06f0fa773..c3ae738bd 100644 --- a/app/security/scala/Auth.scala +++ b/app/security/scala/Auth.scala @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/security/scala/AuthExecutionContext.scala b/app/security/scala/AuthExecutionContext.scala index e79ba6810..37c28bd99 100644 --- a/app/security/scala/AuthExecutionContext.scala +++ b/app/security/scala/AuthExecutionContext.scala @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/system/AuditLogFilter.scala b/app/system/AuditLogFilter.scala index e4d705fe3..c835534a6 100644 --- a/app/system/AuditLogFilter.scala +++ b/app/system/AuditLogFilter.scala @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/system/AuditedAction.scala b/app/system/AuditedAction.scala index 70a9f0a07..da46ac932 100644 --- a/app/system/AuditedAction.scala +++ b/app/system/AuditedAction.scala @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/system/SystemFilter.scala b/app/system/SystemFilter.scala index c727fbc2b..4c6e76ca1 100644 --- a/app/system/SystemFilter.scala +++ b/app/system/SystemFilter.scala @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/system/SystemRequestHandler.java b/app/system/SystemRequestHandler.java index 574b8d935..e3dcef625 100644 --- a/app/system/SystemRequestHandler.java +++ b/app/system/SystemRequestHandler.java @@ -1,5 +1,5 @@ // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/app/validators/ExternalCourseValidator.scala b/app/validators/ExternalCourseValidator.scala index f8ef46632..fc9197340 100644 --- a/app/validators/ExternalCourseValidator.scala +++ b/app/validators/ExternalCourseValidator.scala @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024. The members of the EXAM Consortium +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/ui/src/app/dashboard/student/student-dashboard.component.ts b/ui/src/app/dashboard/student/student-dashboard.component.ts index 562c600bd..79d33df32 100644 --- a/ui/src/app/dashboard/student/student-dashboard.component.ts +++ b/ui/src/app/dashboard/student/student-dashboard.component.ts @@ -5,11 +5,11 @@ import type { OnInit } from '@angular/core'; import { Component, signal } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; +import { DashboardEnrolment } from 'src/app/dashboard/dashboard.model'; import { ActiveEnrolmentComponent } from 'src/app/enrolment/active/active-enrolment.component'; import { PageContentComponent } from 'src/app/shared/components/page-content.component'; import { PageHeaderComponent } from 'src/app/shared/components/page-header.component'; import { OrderByPipe } from 'src/app/shared/sorting/order-by.pipe'; -import type { DashboardEnrolment } from './student-dashboard.service'; import { StudentDashboardService } from './student-dashboard.service'; @Component({ diff --git a/ui/src/app/exam/editor/sections/section-question.component.ts b/ui/src/app/exam/editor/sections/section-question.component.ts index a098447fa..fa0af1b46 100644 --- a/ui/src/app/exam/editor/sections/section-question.component.ts +++ b/ui/src/app/exam/editor/sections/section-question.component.ts @@ -22,6 +22,7 @@ import { map } from 'rxjs/operators'; import type { ExamSection } from 'src/app/exam/exam.model'; import { BaseQuestionEditorComponent } from 'src/app/question/examquestion/base-question-editor.component'; import { ExamQuestionEditorComponent } from 'src/app/question/examquestion/exam-question-editor.component'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { ExamSectionQuestion, ExamSectionQuestionOption, Question } from 'src/app/question/question.model'; import { QuestionService } from 'src/app/question/question.service'; import { AttachmentService } from 'src/app/shared/attachment/attachment.service'; @@ -64,15 +65,16 @@ export class SectionQuestionComponent { private toast: ToastrService, private Confirmation: ConfirmationDialogService, private Question: QuestionService, + private QuestionScore: QuestionScoringService, private Attachment: AttachmentService, private Files: FileService, ) {} - calculateWeightedMaxPoints = () => this.Question.calculateWeightedMaxPoints(this.sectionQuestion.options); + calculateWeightedMaxPoints = () => this.QuestionScore.calculateWeightedMaxPoints(this.sectionQuestion.options); - getCorrectClaimChoiceOptionScore = () => this.Question.getCorrectClaimChoiceOptionScore(this.sectionQuestion); + getCorrectClaimChoiceOptionScore = () => this.QuestionScore.getCorrectClaimChoiceOptionScore(this.sectionQuestion); - getMinimumOptionScore = () => this.Question.getMinimumOptionScore(this.sectionQuestion); + getMinimumOptionScore = () => this.QuestionScore.getMinimumOptionScore(this.sectionQuestion); editQuestion = () => this.openExamQuestionEditor(); diff --git a/ui/src/app/exam/editor/sections/section.component.ts b/ui/src/app/exam/editor/sections/section.component.ts index 03377bea3..2475c8322 100644 --- a/ui/src/app/exam/editor/sections/section.component.ts +++ b/ui/src/app/exam/editor/sections/section.component.ts @@ -30,8 +30,8 @@ import type { ExamMaterial, ExamSection } from 'src/app/exam/exam.model'; import { ExamService } from 'src/app/exam/exam.service'; import { BaseQuestionEditorComponent } from 'src/app/question/examquestion/base-question-editor.component'; import { QuestionSelectorComponent } from 'src/app/question/picker/question-picker.component'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { ExamSectionQuestion, Question } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import { ConfirmationDialogService } from 'src/app/shared/dialogs/confirmation-dialog.service'; import { FileService } from 'src/app/shared/file/file.service'; import { OrderByPipe } from 'src/app/shared/sorting/order-by.pipe'; @@ -77,7 +77,7 @@ export class SectionComponent { private modal: NgbModal, private toast: ToastrService, private dialogs: ConfirmationDialogService, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, private Files: FileService, private Exam: ExamService, ) {} @@ -278,7 +278,7 @@ export class SectionComponent { optional: this.section.optional, }); - private getQuestionScore = (question: ExamSectionQuestion) => this.Question.calculateMaxScore(question); + private getQuestionScore = (question: ExamSectionQuestion) => this.QuestionScore.calculateMaxScore(question); private insertExamQuestion = (question: Question, seq: number) => { const resource = this.collaborative diff --git a/ui/src/app/exam/exam.service.ts b/ui/src/app/exam/exam.service.ts index 60c727cde..a692e1272 100644 --- a/ui/src/app/exam/exam.service.ts +++ b/ui/src/app/exam/exam.service.ts @@ -10,7 +10,7 @@ import { parseISO } from 'date-fns'; import { ToastrService } from 'ngx-toastr'; import type { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { QuestionService } from 'src/app/question/question.service'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { SessionService } from 'src/app/session/session.service'; import { ConfirmationDialogService } from 'src/app/shared/dialogs/confirmation-dialog.service'; import { CommonExamService } from 'src/app/shared/miscellaneous/common-exam.service'; @@ -47,7 +47,7 @@ export class ExamService { private translate: TranslateService, private toast: ToastrService, private CommonExam: CommonExamService, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, private Session: SessionService, private ConfirmationDialog: ConfirmationDialogService, ) {} @@ -253,7 +253,7 @@ export class ExamService { getSectionTotalNumericScore = (section: ExamSection): number => { const score = section.sectionQuestions.reduce((n, sq) => { - const points = this.Question.calculateAnswerScore(sq); + const points = this.QuestionScore.calculateAnswerScore(sq); // handle only numeric scores (leave out approved/rejected type of scores) return n + (points.rejected === false && points.approved === false ? points.score : 0); }, 0); @@ -262,7 +262,7 @@ export class ExamService { getSectionTotalScore = (section: ExamSection): number => { const score = section.sectionQuestions.reduce((n, sq) => { - const points = this.Question.calculateAnswerScore(sq); + const points = this.QuestionScore.calculateAnswerScore(sq); return n + points.score; }, 0); return Number.isInteger(score) ? score : parseFloat(score.toFixed(2)); @@ -273,7 +273,7 @@ export class ExamService { if (!sq || !sq.question) { return n; } - return n + this.Question.calculateMaxScore(sq); + return n + this.QuestionScore.calculateMaxScore(sq); }, 0); if (section.lotteryOn) { maxScore = (maxScore * section.lotteryItemCount) / Math.max(1, section.sectionQuestions.length); diff --git a/ui/src/app/facility/schedule/opening-hours.component.html b/ui/src/app/facility/schedule/opening-hours.component.html new file mode 100644 index 000000000..e69de29bb diff --git a/ui/src/app/facility/schedule/opening-hours.component.ts b/ui/src/app/facility/schedule/opening-hours.component.ts index 9e7fd0ae0..666c35bf1 100644 --- a/ui/src/app/facility/schedule/opening-hours.component.ts +++ b/ui/src/app/facility/schedule/opening-hours.component.ts @@ -26,113 +26,7 @@ interface RoomWithAddressVisibility extends ExamRoom { } @Component({ selector: 'xm-opening-hours', - template: ` - @for (dwh of orderByWeekday(extendedRoom.extendedDwh); track dwh) { -
-
- {{ dateTime.translateWeekdayName(dwh.weekday, true) }} -
-
- @if (!dwh.editing) { -
-
{{ workingHourFormat(dwh.displayStartingTime) }}
-
-
-
{{ workingHourFormat(dwh.displayEndingTime) }}
-
- } - @if (dwh.editing) { -
- -
-
- -
- } -
-
- @if (!dwh.editing) { -
- } - @if (dwh.editing) { -
-
-
-
-
- } -
-
- } -
- - -
- @for (weekday of WEEKDAYS; track weekday) { - - } -
-
-
-
- -
-
- -
-
-
-
-
-
- `, + templateUrl: './opening-hours.component.html', standalone: true, imports: [NgbTimepicker, FormsModule, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem], }) diff --git a/ui/src/app/question/basequestion/multiple-choice.component.ts b/ui/src/app/question/basequestion/multiple-choice.component.ts index cf2d94e63..9f51855da 100644 --- a/ui/src/app/question/basequestion/multiple-choice.component.ts +++ b/ui/src/app/question/basequestion/multiple-choice.component.ts @@ -6,8 +6,8 @@ import { UpperCasePipe } from '@angular/common'; import { Component, Input, OnInit } from '@angular/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ToastrService } from 'ngx-toastr'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { MultipleChoiceOption, Question, QuestionDraft } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import { MultipleChoiceOptionEditorComponent } from './multiple-choice-option.component'; import { WeightedMultipleChoiceOptionEditorComponent } from './weighted-multiple-choice-option.component'; @@ -114,7 +114,7 @@ export class MultipleChoiceEditorComponent implements OnInit { constructor( private translate: TranslateService, private toast: ToastrService, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, ) {} ngOnInit() { @@ -135,5 +135,5 @@ export class MultipleChoiceEditorComponent implements OnInit { this.question.options.push(option); }; - calculateDefaultMaxPoints = () => this.Question.calculateDefaultMaxPoints(this.question as Question); + calculateDefaultMaxPoints = () => this.QuestionScore.calculateDefaultMaxPoints(this.question as Question); } diff --git a/ui/src/app/question/examquestion/weighted-multichoice.component.ts b/ui/src/app/question/examquestion/weighted-multichoice.component.ts index db4ad0a46..8b97616f5 100644 --- a/ui/src/app/question/examquestion/weighted-multichoice.component.ts +++ b/ui/src/app/question/examquestion/weighted-multichoice.component.ts @@ -8,8 +8,8 @@ import { ControlContainer, FormsModule, NgForm } from '@angular/forms'; import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ToastrService } from 'ngx-toastr'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { ExamSectionQuestionOption } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; @Component({ selector: 'xm-eq-weighted-mc', @@ -111,12 +111,12 @@ export class WeightedMultiChoiceComponent { lotteryOn = input(false); isInPublishedExam = input(false); optionsChanged = output(); - maxScore = computed(() => this.QuestionService.calculateWeightedMaxPoints(this.options())); + maxScore = computed(() => this.QuestionScore.calculateWeightedMaxPoints(this.options())); constructor( private TranslateService: TranslateService, private ToastrService: ToastrService, - private QuestionService: QuestionService, + private QuestionScore: QuestionScoringService, ) {} updateScore = (score: number, index: number) => { diff --git a/ui/src/app/question/library/library.service.ts b/ui/src/app/question/library/library.service.ts index ce1ec013b..04c18d674 100644 --- a/ui/src/app/question/library/library.service.ts +++ b/ui/src/app/question/library/library.service.ts @@ -8,8 +8,8 @@ import { SESSION_STORAGE, WebStorageService } from 'ngx-webstorage-service'; import type { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import type { Course, Exam, ExamSection } from 'src/app/exam/exam.model'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { LibraryQuestion, Tag } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import { User } from 'src/app/session/session.model'; import { UserService } from 'src/app/shared/user/user.service'; @@ -18,7 +18,7 @@ export class LibraryService { constructor( private http: HttpClient, @Inject(SESSION_STORAGE) private webStorageService: WebStorageService, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, private User: UserService, ) {} @@ -208,9 +208,9 @@ export class LibraryService { } else if (q.defaultEvaluationType === 'Selection') { return 'i18n_evaluation_select'; } else if (q.type === 'WeightedMultipleChoiceQuestion') { - return this.Question.calculateDefaultMaxPoints(q); + return this.QuestionScore.calculateDefaultMaxPoints(q); } else if (q.type === 'ClaimChoiceQuestion') { - return this.Question.getCorrectClaimChoiceOptionDefaultScore(q); + return this.QuestionScore.getCorrectClaimChoiceOptionDefaultScore(q); } return ''; }; diff --git a/ui/src/app/question/question-scoring.service.ts b/ui/src/app/question/question-scoring.service.ts new file mode 100644 index 000000000..ba02e05df --- /dev/null +++ b/ui/src/app/question/question-scoring.service.ts @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium +// +// SPDX-License-Identifier: EUPL-1.2 + +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Exam, ExamSection } from 'src/app/exam/exam.model'; +import { isNumber } from 'src/app/shared/miscellaneous/helpers'; +import { ExamSectionQuestion, ExamSectionQuestionOption, Question, QuestionAmounts } from './question.model'; + +@Injectable({ providedIn: 'root' }) +export class QuestionScoringService { + constructor(private httpClient: HttpClient) {} + + getQuestionAmounts = (exam: Exam): QuestionAmounts => { + const essays = exam.examSections + .flatMap((es) => es.sectionQuestions) + .filter((esq) => esq.question.type === 'EssayQuestion'); + const scores = essays + .filter((e) => e.evaluationType === 'Selection' && e.essayAnswer) + .map((e) => e.essayAnswer?.evaluatedScore); + const accepted = scores.filter((s) => s === 1).length; + const rejected = scores.filter((s) => s === 0).length; + return { accepted: accepted, rejected: rejected, hasEssays: essays.length > 0 }; + }; + + getEssayQuestionAmountsBySection = (section: ExamSection) => { + const scores = section.sectionQuestions + .filter((sq) => sq.question.type === 'EssayQuestion' && sq.evaluationType === 'Selection' && sq.essayAnswer) + .map((sq) => sq.essayAnswer?.evaluatedScore); + return { + accepted: scores.filter((s) => s === 1).length, + rejected: scores.filter((s) => s === 0).length, + total: scores.length, + }; + }; + + calculateDefaultMaxPoints = (question: Question) => + question.options.filter((o) => o.defaultScore > 0).reduce((a, b) => a + b.defaultScore, 0); + + getMinimumOptionScore = (sectionQuestion: ExamSectionQuestion): number => { + const optionScores = sectionQuestion.options.map((o) => o.score); + const scores = [0, ...optionScores]; // Make sure 0 is included + return sectionQuestion.question.type === 'WeightedMultipleChoiceQuestion' + ? Math.max(0, Math.min(...scores)) // Weighted mcq mustn't have a negative min score + : Math.min(...scores); + }; + + getCorrectClaimChoiceOptionDefaultScore = (question: Question): number => { + if (!question.options) { + return 0; + } + const correctOption = question.options.filter((o) => o.correctOption && o.claimChoiceType === 'CorrectOption'); + return correctOption.length === 1 ? correctOption[0].defaultScore : 0; + }; + + scoreClozeTestAnswer = (sectionQuestion: ExamSectionQuestion): number => { + if (!sectionQuestion.clozeTestAnswer) { + return 0; + } + if (isNumber(sectionQuestion.forcedScore)) { + return sectionQuestion.forcedScore; + } + const score = sectionQuestion.clozeTestAnswer.score; + if (!score) return 0; + const proportion = + (score.correctAnswers * sectionQuestion.maxScore) / (score.correctAnswers + score.incorrectAnswers); + return parseFloat(proportion.toFixed(2)); + }; + + scoreWeightedMultipleChoiceAnswer = (sectionQuestion: ExamSectionQuestion, ignoreForcedScore: boolean): number => { + if (isNumber(sectionQuestion.forcedScore) && !ignoreForcedScore) { + return sectionQuestion.forcedScore; + } + const score = sectionQuestion.options.filter((o) => o.answered).reduce((a, b) => a + b.score, 0); + return Math.max(0, score); + }; + + // For non-weighted mcq + scoreMultipleChoiceAnswer = (sectionQuestion: ExamSectionQuestion, ignoreForcedScore: boolean): number => { + if (isNumber(sectionQuestion.forcedScore) && !ignoreForcedScore) { + return sectionQuestion.forcedScore; + } + const answered = sectionQuestion.options.filter((o) => o.answered); + if (answered.length === 0) { + // No answer + return 0; + } + if (answered.length !== 1) { + console.error('multiple options selected for a MultiChoice answer!'); + } + + return answered[0].option.correctOption ? sectionQuestion.maxScore : 0; + }; + + scoreClaimChoiceAnswer = (sectionQuestion: ExamSectionQuestion, ignoreForcedScore: boolean): number => { + if (isNumber(sectionQuestion.forcedScore) && !ignoreForcedScore) { + return sectionQuestion.forcedScore; + } + const selected = sectionQuestion.options.filter((o) => o.answered); + + // Use the score from the skip option if no option was chosen + const skipOption = sectionQuestion.options.filter((o) => o.option.claimChoiceType === 'SkipOption'); + const skipScore = skipOption.length === 1 ? skipOption[0].score : 0; + + if (selected.length === 0) { + return skipScore; + } + if (selected.length !== 1) { + console.error('multiple options selected for a ClaimChoice answer!'); + } + if (selected[0].score && isNumber(selected[0].score)) { + return selected[0].score; + } + return 0; + }; + + calculateAnswerScore = (sq: ExamSectionQuestion) => { + switch (sq.question.type) { + case 'MultipleChoiceQuestion': + return { score: this.scoreMultipleChoiceAnswer(sq, false), rejected: false, approved: false }; + case 'WeightedMultipleChoiceQuestion': + return { score: this.scoreWeightedMultipleChoiceAnswer(sq, false), rejected: false, approved: false }; + case 'ClozeTestQuestion': + return { score: this.scoreClozeTestAnswer(sq), rejected: false, approved: false }; + case 'EssayQuestion': + if (sq.essayAnswer && sq.essayAnswer.evaluatedScore && sq.evaluationType === 'Points') { + return { score: sq.essayAnswer.evaluatedScore, rejected: false, approved: false }; + } else if (sq.essayAnswer && sq.essayAnswer.evaluatedScore && sq.evaluationType === 'Selection') { + const score = sq.essayAnswer.evaluatedScore; + return { score: score, rejected: score === 0, approved: score === 1 }; + } + return { score: 0, rejected: false, approved: false }; + case 'ClaimChoiceQuestion': + return { score: this.scoreClaimChoiceAnswer(sq, false), rejected: false, approved: false }; + default: + throw Error('unknown question type'); + } + }; + + calculateWeightedMaxPoints = (options: ExamSectionQuestionOption[]): number => { + const points = options.filter((o) => o.score > 0).reduce((a, b) => a + b.score, 0); + return parseFloat(points.toFixed(2)); + }; + + getCorrectClaimChoiceOptionScore = (sectionQuestion: ExamSectionQuestion): number => { + if (!sectionQuestion.options) { + return 0; + } + const optionScores = sectionQuestion.options.map((o) => o.score); + return Math.max(0, ...optionScores); + }; + + calculateMaxScore = (question: ExamSectionQuestion) => { + const evaluationType = question.evaluationType; + const type = question.question.type; + if (evaluationType === 'Points' || type === 'MultipleChoiceQuestion' || type === 'ClozeTestQuestion') { + return question.maxScore; + } + if (type === 'WeightedMultipleChoiceQuestion') { + return this.calculateWeightedMaxPoints(question.options); + } + if (type === 'ClaimChoiceQuestion') { + return this.getCorrectClaimChoiceOptionScore(question); + } + return 0; + }; +} diff --git a/ui/src/app/question/question.service.ts b/ui/src/app/question/question.service.ts index 4cc3ec483..588e8ce34 100644 --- a/ui/src/app/question/question.service.ts +++ b/ui/src/app/question/question.service.ts @@ -8,17 +8,14 @@ import { TranslateService } from '@ngx-translate/core'; import { ToastrService } from 'ngx-toastr'; import type { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import type { Exam, ExamSection } from 'src/app/exam/exam.model'; import { SessionService } from 'src/app/session/session.service'; import { AttachmentService } from 'src/app/shared/attachment/attachment.service'; import { FileService } from 'src/app/shared/file/file.service'; -import { isNumber } from 'src/app/shared/miscellaneous/helpers'; import { ExamSectionQuestion, ExamSectionQuestionOption, MultipleChoiceOption, Question, - QuestionAmounts, QuestionDraft, ReverseQuestion, } from './question.model'; @@ -73,160 +70,6 @@ export class QuestionService { getQuestion = (id: number): Observable => this.http.get(this.questionsApi(id)); - getQuestionAmounts = (exam: Exam): QuestionAmounts => { - const essays = exam.examSections - .flatMap((es) => es.sectionQuestions) - .filter((esq) => esq.question.type === 'EssayQuestion'); - const scores = essays - .filter((e) => e.evaluationType === 'Selection' && e.essayAnswer) - .map((e) => e.essayAnswer?.evaluatedScore); - const accepted = scores.filter((s) => s === 1).length; - const rejected = scores.filter((s) => s === 0).length; - return { accepted: accepted, rejected: rejected, hasEssays: essays.length > 0 }; - }; - - getEssayQuestionAmountsBySection = (section: ExamSection) => { - const scores = section.sectionQuestions - .filter((sq) => sq.question.type === 'EssayQuestion' && sq.evaluationType === 'Selection' && sq.essayAnswer) - .map((sq) => sq.essayAnswer?.evaluatedScore); - return { - accepted: scores.filter((s) => s === 1).length, - rejected: scores.filter((s) => s === 0).length, - total: scores.length, - }; - }; - - calculateDefaultMaxPoints = (question: Question) => - question.options.filter((o) => o.defaultScore > 0).reduce((a, b) => a + b.defaultScore, 0); - - getMinimumOptionScore = (sectionQuestion: ExamSectionQuestion): number => { - const optionScores = sectionQuestion.options.map((o) => o.score); - const scores = [0, ...optionScores]; // Make sure 0 is included - return sectionQuestion.question.type === 'WeightedMultipleChoiceQuestion' - ? Math.max(0, Math.min(...scores)) // Weighted mcq mustn't have a negative min score - : Math.min(...scores); - }; - - getCorrectClaimChoiceOptionDefaultScore = (question: Question): number => { - if (!question.options) { - return 0; - } - const correctOption = question.options.filter((o) => o.correctOption && o.claimChoiceType === 'CorrectOption'); - return correctOption.length === 1 ? correctOption[0].defaultScore : 0; - }; - - scoreClozeTestAnswer = (sectionQuestion: ExamSectionQuestion): number => { - if (!sectionQuestion.clozeTestAnswer) { - return 0; - } - if (isNumber(sectionQuestion.forcedScore)) { - return sectionQuestion.forcedScore; - } - const score = sectionQuestion.clozeTestAnswer.score; - if (!score) return 0; - const proportion = - (score.correctAnswers * sectionQuestion.maxScore) / (score.correctAnswers + score.incorrectAnswers); - return parseFloat(proportion.toFixed(2)); - }; - - scoreWeightedMultipleChoiceAnswer = (sectionQuestion: ExamSectionQuestion, ignoreForcedScore: boolean): number => { - if (isNumber(sectionQuestion.forcedScore) && !ignoreForcedScore) { - return sectionQuestion.forcedScore; - } - const score = sectionQuestion.options.filter((o) => o.answered).reduce((a, b) => a + b.score, 0); - return Math.max(0, score); - }; - - // For non-weighted mcq - scoreMultipleChoiceAnswer = (sectionQuestion: ExamSectionQuestion, ignoreForcedScore: boolean): number => { - if (isNumber(sectionQuestion.forcedScore) && !ignoreForcedScore) { - return sectionQuestion.forcedScore; - } - const answered = sectionQuestion.options.filter((o) => o.answered); - if (answered.length === 0) { - // No answer - return 0; - } - if (answered.length !== 1) { - console.error('multiple options selected for a MultiChoice answer!'); - } - - return answered[0].option.correctOption ? sectionQuestion.maxScore : 0; - }; - - scoreClaimChoiceAnswer = (sectionQuestion: ExamSectionQuestion, ignoreForcedScore: boolean): number => { - if (isNumber(sectionQuestion.forcedScore) && !ignoreForcedScore) { - return sectionQuestion.forcedScore; - } - const selected = sectionQuestion.options.filter((o) => o.answered); - - // Use the score from the skip option if no option was chosen - const skipOption = sectionQuestion.options.filter((o) => o.option.claimChoiceType === 'SkipOption'); - const skipScore = skipOption.length === 1 ? skipOption[0].score : 0; - - if (selected.length === 0) { - return skipScore; - } - if (selected.length !== 1) { - console.error('multiple options selected for a ClaimChoice answer!'); - } - if (selected[0].score && isNumber(selected[0].score)) { - return selected[0].score; - } - return 0; - }; - - calculateAnswerScore = (sq: ExamSectionQuestion) => { - switch (sq.question.type) { - case 'MultipleChoiceQuestion': - return { score: this.scoreMultipleChoiceAnswer(sq, false), rejected: false, approved: false }; - case 'WeightedMultipleChoiceQuestion': - return { score: this.scoreWeightedMultipleChoiceAnswer(sq, false), rejected: false, approved: false }; - case 'ClozeTestQuestion': - return { score: this.scoreClozeTestAnswer(sq), rejected: false, approved: false }; - case 'EssayQuestion': - if (sq.essayAnswer && sq.essayAnswer.evaluatedScore && sq.evaluationType === 'Points') { - return { score: sq.essayAnswer.evaluatedScore, rejected: false, approved: false }; - } else if (sq.essayAnswer && sq.essayAnswer.evaluatedScore && sq.evaluationType === 'Selection') { - const score = sq.essayAnswer.evaluatedScore; - return { score: score, rejected: score === 0, approved: score === 1 }; - } - return { score: 0, rejected: false, approved: false }; - case 'ClaimChoiceQuestion': - return { score: this.scoreClaimChoiceAnswer(sq, false), rejected: false, approved: false }; - default: - throw Error('unknown question type'); - } - }; - - calculateWeightedMaxPoints = (options: ExamSectionQuestionOption[]): number => { - const points = options.filter((o) => o.score > 0).reduce((a, b) => a + b.score, 0); - return parseFloat(points.toFixed(2)); - }; - - getCorrectClaimChoiceOptionScore = (sectionQuestion: ExamSectionQuestion): number => { - if (!sectionQuestion.options) { - return 0; - } - const optionScores = sectionQuestion.options.map((o) => o.score); - return Math.max(0, ...optionScores); - }; - - calculateMaxScore = (question: ExamSectionQuestion) => { - const evaluationType = question.evaluationType; - const type = question.question.type; - if (evaluationType === 'Points' || type === 'MultipleChoiceQuestion' || type === 'ClozeTestQuestion') { - return question.maxScore; - } - if (type === 'WeightedMultipleChoiceQuestion') { - return this.calculateWeightedMaxPoints(question.options); - } - if (type === 'ClaimChoiceQuestion') { - return this.getCorrectClaimChoiceOptionScore(question); - } - return 0; - }; - createQuestion = (question: QuestionDraft): Promise => { const body = this.getQuestionData(question); // TODO: make this a pipe diff --git a/ui/src/app/reservation/reservation-details.component.ts b/ui/src/app/reservation/reservation-details.component.ts index 379537758..d0917e614 100644 --- a/ui/src/app/reservation/reservation-details.component.ts +++ b/ui/src/app/reservation/reservation-details.component.ts @@ -15,9 +15,8 @@ import { CourseCodeComponent } from 'src/app/shared/miscellaneous/course-code.co import { OrderByPipe } from 'src/app/shared/sorting/order-by.pipe'; import { TableSortComponent } from 'src/app/shared/sorting/table-sort.component'; import { TeacherListComponent } from 'src/app/shared/user/teacher-list.component'; -import type { Reservation } from './reservation.model'; +import type { AnyReservation, Reservation } from './reservation.model'; import { ReservationService } from './reservation.service'; -import { AnyReservation } from './reservations.component'; type ReservationDetail = Reservation & { org: { name: string; code: string }; userAggregate: string }; diff --git a/ui/src/app/reservation/reservation.model.ts b/ui/src/app/reservation/reservation.model.ts index 990ec9072..e02ec4e7f 100644 --- a/ui/src/app/reservation/reservation.model.ts +++ b/ui/src/app/reservation/reservation.model.ts @@ -3,8 +3,10 @@ // SPDX-License-Identifier: EUPL-1.2 import type { ExamEnrolment } from 'src/app/enrolment/enrolment.model'; +import { CollaborativeExam, Implementation } from 'src/app/exam/exam.model'; import { Address, WorkingHour } from 'src/app/facility/facility.model'; import type { User } from 'src/app/session/session.model'; +import { isObject } from 'src/app/shared/miscellaneous/helpers'; export type DefaultWorkingHours = { id?: number; @@ -100,3 +102,47 @@ export interface Reservation { endAt: string; user: User; } + +// All of this is needed to put all our reservations in one basket :D +type ExamEnrolmentDisplay = ExamEnrolment & { teacherAggregate: string }; +type MachineDisplay = Omit & { room: Partial }; +type ReservationDisplay = Omit & { + machine: Partial; + userAggregate: string; + stateOrd: number; + enrolment: ExamEnrolmentDisplay; +}; +export type LocalTransferExamEnrolment = Omit & { + exam: { id: number; external: true; examOwners: User[]; state: string; parent: null }; +}; +type CollaborativeExamEnrolment = Omit & { + exam: CollaborativeExam & { examOwners: User[]; parent: null; implementation: Implementation }; +}; +export type LocalTransferExamReservation = Omit & { + enrolment: LocalTransferExamEnrolment; +}; +export type RemoteTransferExamReservation = Omit & { + enrolment: ExamEnrolmentDisplay; + org: { name: string; code: string }; +}; +type CollaborativeExamReservation = Omit & { + enrolment: CollaborativeExamEnrolment; +}; + +export type AnyReservation = + | ReservationDisplay + | LocalTransferExamReservation + | RemoteTransferExamReservation + | CollaborativeExamReservation; + +// Transfer examination taking place here +export function isLocalTransfer(reservation: AnyReservation): reservation is LocalTransferExamReservation { + return !reservation.enrolment || isObject(reservation.enrolment.externalExam); +} +// Transfer examination taking place elsewhere +export function isRemoteTransfer(reservation: AnyReservation): reservation is RemoteTransferExamReservation { + return isObject(reservation.externalReservation); +} +export function isCollaborative(reservation: AnyReservation): reservation is CollaborativeExamReservation { + return isObject(reservation.enrolment?.collaborativeExam); +} diff --git a/ui/src/app/reservation/reservation.service.ts b/ui/src/app/reservation/reservation.service.ts index 8eff83cda..cb6cc0f7b 100644 --- a/ui/src/app/reservation/reservation.service.ts +++ b/ui/src/app/reservation/reservation.service.ts @@ -2,18 +2,37 @@ // // SPDX-License-Identifier: EUPL-1.2 +import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { parseISO } from 'date-fns'; -import { noop } from 'rxjs'; -import type { Exam } from 'src/app/exam/exam.model'; +import { addMinutes, parseISO } from 'date-fns'; +import { debounceTime, distinctUntilChanged, exhaustMap, forkJoin, from, map, noop, Observable, of } from 'rxjs'; +import { ExamEnrolment } from 'src/app/enrolment/enrolment.model'; +import type { CollaborativeExam, Exam } from 'src/app/exam/exam.model'; +import { User } from 'src/app/session/session.model'; import { ChangeMachineDialogComponent } from './admin/change-machine-dialog.component'; import { RemoveReservationDialogComponent } from './admin/remove-reservation-dialog.component'; -import type { ExamMachine, Reservation } from './reservation.model'; +import { + isCollaborative, + isLocalTransfer, + isRemoteTransfer, + RemoteTransferExamReservation, + type AnyReservation, + type ExamMachine, + type LocalTransferExamEnrolment, + type LocalTransferExamReservation, + type Reservation, +} from './reservation.model'; +export interface Selection { + [data: string]: string; +} @Injectable({ providedIn: 'root' }) export class ReservationService { - constructor(private modal: NgbModal) {} + constructor( + private http: HttpClient, + private modal: NgbModal, + ) {} printExamState = (reservation: { enrolment: { exam: { state: string }; collaborativeExam: { state: string }; noShow: boolean }; @@ -55,4 +74,161 @@ export class ReservationService { modalRef.componentInstance.reservation = reservation; return modalRef.result; }; + + listReservations$ = (params: Selection) => { + // Do not fetch byod exams if machine id, room id or external ref in the query params. + // Also applies if searching for external reservations + const eventRequest = + params.roomId || params.machineId || params.externalRef || params.state?.startsWith('EXTERNAL_') + ? of([]) + : this.http.get('/app/events', { params: params }); + return forkJoin([this.http.get('/app/reservations', { params: params }), eventRequest]).pipe( + map(([reservations, enrolments]) => { + const events: Partial[] = enrolments.map((ee) => { + return { + user: ee.user, + enrolment: ee, + startAt: ee.examinationEventConfiguration?.examinationEvent.start, + endAt: addMinutes( + parseISO(ee.examinationEventConfiguration?.examinationEvent.start as string), + ee.exam.duration, + ).toISOString(), + }; + }); + const allEvents: Partial[] = reservations; + return allEvents.concat(events) as Reservation[]; // FIXME: this is wrong(?) <- don't know how to model anymore with strict checking + }), + map((reservations: Reservation[]) => + reservations.map((r) => ({ + ...r, + userAggregate: r.user + ? `${r.user.lastName} ${r.user.firstName}` + : r.externalUserRef + ? r.externalUserRef + : r.enrolment?.exam + ? r.enrolment.exam.id.toString() + : '', + org: '', + stateOrd: 0, + enrolment: r.enrolment ? { ...r.enrolment, teacherAggregate: '' } : r.enrolment, + })), + ), + map((reservations: AnyReservation[]) => { + // Transfer exams taken here + reservations.filter(isLocalTransfer).forEach((r: LocalTransferExamReservation) => { + const state = r.enrolment?.externalExam?.finished ? 'EXTERNAL_FINISHED' : 'EXTERNAL_UNFINISHED'; + const enrolment: LocalTransferExamEnrolment = { + ...r.enrolment, + exam: { + id: r.enrolment?.externalExam?.id as number, + external: true, + examOwners: [], + state: state, + parent: null, + }, + }; + r.enrolment = enrolment; + }); + // Transfer exams taken elsewhere + reservations.filter(isRemoteTransfer).forEach((r: RemoteTransferExamReservation) => { + if (r.externalReservation) { + r.org = { name: r.externalReservation.orgName, code: r.externalReservation.orgCode }; + r.machine = { + name: r.externalReservation.machineName, + room: { name: r.externalReservation.roomName }, + }; + } + }); + // Collaborative exams + reservations.filter(isCollaborative).forEach((r) => { + if (!r.enrolment.exam) { + r.enrolment.exam = { + ...r.enrolment.collaborativeExam, + examOwners: [], + parent: null, + implementation: 'AQUARIUM', + }; + } else { + r.enrolment.exam.examOwners = []; + } + }); + + return reservations; + }), + map((reservations: AnyReservation[]) => reservations.filter((r) => r.enrolment?.exam)), + map((reservations: AnyReservation[]) => { + reservations.forEach((r) => { + const exam = (r.enrolment?.exam.parent || r.enrolment?.exam) as Exam; + r.enrolment = { + ...(r.enrolment as ExamEnrolment), + teacherAggregate: exam.examOwners.map((o) => o.lastName + o.firstName).join(), + }; + const state = this.printExamState(r) as string; + r.stateOrd = [ + 'PUBLISHED', + 'NO_SHOW', + 'STUDENT_STARTED', + 'ABORTED', + 'REVIEW', + 'REVIEW_STARTED', + 'GRADED', + 'GRADED_LOGGED', + 'REJECTED', + 'ARCHIVED', + 'EXTERNAL_UNFINISHED', + 'EXTERNAL_FINISHED', + ].indexOf(state); + }); + return reservations; + }), + ); + }; + + searchStudents$ = (text$: Observable) => + text$.pipe( + debounceTime(300), + distinctUntilChanged(), + exhaustMap((term) => + term.length < 2 + ? from([]) + : this.http.get<(User & { name: string })[]>('/app/reservations/students', { + params: { filter: term }, + }), + ), + map((ss) => ss.sort((a, b) => a.firstName.localeCompare(b.firstName)).slice(0, 100)), + ); + + searchOwners$ = (text$: Observable) => + text$.pipe( + debounceTime(300), + distinctUntilChanged(), + exhaustMap((term) => + term.length < 2 + ? from([]) + : this.http.get<(User & { name: string })[]>('/app/reservations/teachers', { + params: { filter: term }, + }), + ), + map((ss) => ss.sort((a, b) => a.lastName.localeCompare(b.lastName)).slice(0, 100)), + ); + + searchExams$ = (text$: Observable, includeCollaboratives = false) => { + const listExams$ = (text: string) => { + const examObservables: Observable[] = [ + this.http.get('/app/reservations/exams', { params: { filter: text } }), + ]; + if (includeCollaboratives) { + examObservables.push( + this.http.get('/app/iop/exams', { params: { filter: text } }), + ); + } + return forkJoin(examObservables).pipe(map((exams) => exams.flat())); + }; + return text$.pipe( + debounceTime(300), + distinctUntilChanged(), + exhaustMap((term) => (term.length < 2 ? from([]) : listExams$(term))), + map((es) => es.sort((a, b) => (a.name as string).localeCompare(b.name as string)).slice(0, 100)), + ); + }; } diff --git a/ui/src/app/reservation/reservations.component.ts b/ui/src/app/reservation/reservations.component.ts index b35b48401..2662aa9c1 100644 --- a/ui/src/app/reservation/reservations.component.ts +++ b/ui/src/app/reservation/reservations.component.ts @@ -8,59 +8,21 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { NgbTypeaheadModule, NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; -import { addMinutes, endOfDay, parseISO, startOfDay } from 'date-fns'; +import { endOfDay, startOfDay } from 'date-fns'; import { ToastrService } from 'ngx-toastr'; -import { Observable, forkJoin, from, of } from 'rxjs'; -import { debounceTime, distinctUntilChanged, exhaustMap, map } from 'rxjs/operators'; -import type { ExamEnrolment } from 'src/app/enrolment/enrolment.model'; -import type { CollaborativeExam, Exam, ExamImpl, Implementation } from 'src/app/exam/exam.model'; +import { Observable } from 'rxjs'; +import type { CollaborativeExam, Exam, ExamImpl } from 'src/app/exam/exam.model'; import type { User } from 'src/app/session/session.model'; import { SessionService } from 'src/app/session/session.service'; import { PageContentComponent } from 'src/app/shared/components/page-content.component'; import { PageHeaderComponent } from 'src/app/shared/components/page-header.component'; import { DatePickerComponent } from 'src/app/shared/date/date-picker.component'; -import { isObject } from 'src/app/shared/miscellaneous/helpers'; import { DropdownSelectComponent } from 'src/app/shared/select/dropdown-select.component'; import { Option } from 'src/app/shared/select/select.model'; import { OrderByPipe } from 'src/app/shared/sorting/order-by.pipe'; import { ReservationDetailsComponent } from './reservation-details.component'; -import type { ExamMachine, ExamRoom, Reservation } from './reservation.model'; -import { ReservationService } from './reservation.service'; - -interface Selection { - [data: string]: string; -} - -// All of this is needed to put all our reservations in one basket :D -type ExamEnrolmentDisplay = ExamEnrolment & { teacherAggregate: string }; -type MachineDisplay = Omit & { room: Partial }; -type ReservationDisplay = Omit & { - machine: Partial; - userAggregate: string; - stateOrd: number; - enrolment: ExamEnrolmentDisplay; -}; -type LocalTransferExamEnrolment = Omit & { - exam: { id: number; external: true; examOwners: User[]; state: string; parent: null }; -}; -type CollaborativeExamEnrolment = Omit & { - exam: CollaborativeExam & { examOwners: User[]; parent: null; implementation: Implementation }; -}; -type LocalTransferExamReservation = Omit & { - enrolment: LocalTransferExamEnrolment; -}; -type RemoteTransferExamReservation = Omit & { - enrolment: ExamEnrolmentDisplay; - org: { name: string; code: string }; -}; -type CollaborativeExamReservation = Omit & { - enrolment: CollaborativeExamEnrolment; -}; -export type AnyReservation = - | ReservationDisplay - | LocalTransferExamReservation - | RemoteTransferExamReservation - | CollaborativeExamReservation; +import type { AnyReservation, ExamMachine, ExamRoom } from './reservation.model'; +import { ReservationService, Selection } from './reservation.service'; @Component({ selector: 'xm-reservations', @@ -140,128 +102,18 @@ export class ReservationsComponent implements OnInit { query() { if (this.somethingSelected(this.selection)) { const params = this.createParams(this.selection); - // Do not fetch byod exams if machine id, room id or external ref in the query params. - // Also applies if searching for external reservations - const eventRequest = - params.roomId || params.machineId || params.externalRef || params.state?.startsWith('EXTERNAL_') - ? of([]) - : this.http.get('/app/events', { params: params }); - forkJoin([this.http.get('/app/reservations', { params: params }), eventRequest]) - .pipe( - map(([reservations, enrolments]) => { - const events: Partial[] = enrolments.map((ee) => { - return { - user: ee.user, - enrolment: ee, - startAt: ee.examinationEventConfiguration?.examinationEvent.start, - endAt: addMinutes( - parseISO(ee.examinationEventConfiguration?.examinationEvent.start as string), - ee.exam.duration, - ).toISOString(), - }; - }); - const allEvents: Partial[] = reservations; - return allEvents.concat(events) as Reservation[]; // FIXME: this is wrong(?) <- don't know how to model anymore with strict checking - }), - map((reservations: Reservation[]) => - reservations.map((r) => ({ - ...r, - userAggregate: r.user - ? `${r.user.lastName} ${r.user.firstName}` - : r.externalUserRef - ? r.externalUserRef - : r.enrolment?.exam - ? r.enrolment.exam.id.toString() - : '', - org: '', - stateOrd: 0, - enrolment: r.enrolment ? { ...r.enrolment, teacherAggregate: '' } : r.enrolment, - })), - ), - map((reservations: AnyReservation[]) => { - // Transfer exams taken here - reservations.filter(this.isLocalTransfer).forEach((r: LocalTransferExamReservation) => { - const state = r.enrolment?.externalExam?.finished - ? 'EXTERNAL_FINISHED' - : 'EXTERNAL_UNFINISHED'; - const enrolment: LocalTransferExamEnrolment = { - ...r.enrolment, - exam: { - id: r.enrolment?.externalExam?.id as number, - external: true, - examOwners: [], - state: state, - parent: null, - }, - }; - r.enrolment = enrolment; - }); - // Transfer exams taken elsewhere - reservations.filter(this.isRemoteTransfer).forEach((r: RemoteTransferExamReservation) => { - if (r.externalReservation) { - r.org = { name: r.externalReservation.orgName, code: r.externalReservation.orgCode }; - r.machine = { - name: r.externalReservation.machineName, - room: { name: r.externalReservation.roomName }, - }; - } - }); - // Collaborative exams - reservations.filter(this.isCollaborative).forEach((r) => { - if (!r.enrolment.exam) { - r.enrolment.exam = { - ...r.enrolment.collaborativeExam, - examOwners: [], - parent: null, - implementation: 'AQUARIUM', - }; - } else { - r.enrolment.exam.examOwners = []; - } - }); - - return reservations; - }), - map((reservations: AnyReservation[]) => reservations.filter((r) => r.enrolment?.exam)), - map((reservations: AnyReservation[]) => { - reservations.forEach((r) => { - const exam = (r.enrolment?.exam.parent || r.enrolment?.exam) as Exam; - r.enrolment = { - ...(r.enrolment as ExamEnrolment), - teacherAggregate: exam.examOwners.map((o) => o.lastName + o.firstName).join(), - }; - const state = this.Reservation.printExamState(r) as string; - r.stateOrd = [ - 'PUBLISHED', - 'NO_SHOW', - 'STUDENT_STARTED', - 'ABORTED', - 'REVIEW', - 'REVIEW_STARTED', - 'GRADED', - 'GRADED_LOGGED', - 'REJECTED', - 'ARCHIVED', - 'EXTERNAL_UNFINISHED', - 'EXTERNAL_FINISHED', - ].indexOf(state); - }); - return reservations; - }), - ) - .subscribe({ - next: (reservations) => { - this.reservations = reservations - .filter((r) => r.externalReservation || !this.externalReservationsOnly) - .filter( - (r) => - (!r.externalUserRef && - (r.enrolment.exam as ExamImpl).implementation !== 'AQUARIUM') || - !this.byodExamsOnly, - ); - }, - error: (err) => this.toast.error(err), - }); + this.Reservation.listReservations$(params).subscribe({ + next: (reservations) => { + this.reservations = reservations + .filter((r) => r.externalReservation || !this.externalReservationsOnly) + .filter( + (r) => + (!r.externalUserRef && (r.enrolment.exam as ExamImpl).implementation !== 'AQUARIUM') || + !this.byodExamsOnly, + ); + }, + error: (err) => this.toast.error(err), + }); } } @@ -346,53 +198,12 @@ export class ReservationsComponent implements OnInit { this.query(); } - protected searchStudents$ = (text$: Observable) => - text$.pipe( - debounceTime(300), - distinctUntilChanged(), - exhaustMap((term) => - term.length < 2 - ? from([]) - : this.http.get<(User & { name: string })[]>('/app/reservations/students', { - params: { filter: term }, - }), - ), - map((ss) => ss.sort((a, b) => a.firstName.localeCompare(b.firstName)).slice(0, 100)), - ); + protected searchStudents$ = (text$: Observable) => this.Reservation.searchStudents$(text$); - protected searchOwners$ = (text$: Observable) => - text$.pipe( - debounceTime(300), - distinctUntilChanged(), - exhaustMap((term) => - term.length < 2 - ? from([]) - : this.http.get<(User & { name: string })[]>('/app/reservations/teachers', { - params: { filter: term }, - }), - ), - map((ss) => ss.sort((a, b) => a.lastName.localeCompare(b.lastName)).slice(0, 100)), - ); + protected searchOwners$ = (text$: Observable) => this.Reservation.searchOwners$(text$); - protected searchExams$ = (text$: Observable) => { - const listExams$ = (text: string) => { - const examObservables: Observable[] = [ - this.http.get('/app/reservations/exams', { params: { filter: text } }), - ]; - if (this.isInteroperable && this.isAdminView()) { - examObservables.push( - this.http.get('/app/iop/exams', { params: { filter: text } }), - ); - } - return forkJoin(examObservables).pipe(map((exams) => exams.flat())); - }; - return text$.pipe( - debounceTime(300), - distinctUntilChanged(), - exhaustMap((term) => (term.length < 2 ? from([]) : listExams$(term))), - map((es) => es.sort((a, b) => (a.name as string).localeCompare(b.name as string)).slice(0, 100)), - ); - }; + protected searchExams$ = (text$: Observable) => + this.Reservation.searchExams$(text$, this.isInteroperable && this.isAdminView()); protected nameFormatter = (item: { name: string }) => item.name; @@ -413,15 +224,6 @@ export class ReservationsComponent implements OnInit { return params; }; - // Transfer examination taking place here - private isLocalTransfer = (reservation: AnyReservation): reservation is LocalTransferExamReservation => - !reservation.enrolment || isObject(reservation.enrolment.externalExam); - // Transfer examination taking place elsewhere - private isRemoteTransfer = (reservation: AnyReservation): reservation is RemoteTransferExamReservation => - isObject(reservation.externalReservation); - private isCollaborative = (reservation: AnyReservation): reservation is CollaborativeExamReservation => - isObject(reservation.enrolment?.collaborativeExam); - private initOptions() { this.http.get<{ isExamVisitSupported: boolean }>('/app/settings/iop/examVisit').subscribe((resp) => { this.isInteroperable = resp.isExamVisitSupported; diff --git a/ui/src/app/review/assessment/assessment.component.ts b/ui/src/app/review/assessment/assessment.component.ts index e27836207..fd4ff4d99 100644 --- a/ui/src/app/review/assessment/assessment.component.ts +++ b/ui/src/app/review/assessment/assessment.component.ts @@ -11,9 +11,9 @@ import { ToastrService } from 'ngx-toastr'; import { ExamParticipation } from 'src/app/enrolment/enrolment.model'; import { ExamService } from 'src/app/exam/exam.service'; import type { Examination } from 'src/app/examination/examination.model'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import type { QuestionAmounts } from 'src/app/question/question.model'; import { ClozeTestAnswer } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import type { User } from 'src/app/session/session.model'; import { SessionService } from 'src/app/session/session.service'; import { PageContentComponent } from 'src/app/shared/components/page-content.component'; @@ -66,7 +66,7 @@ export class AssessmentComponent implements OnInit { private toast: ToastrService, private Assessment: AssessmentService, private CollaborativeAssessment: CollaborativeAssesmentService, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, private Exam: ExamService, private Session: SessionService, ) { @@ -98,7 +98,7 @@ export class AssessmentComponent implements OnInit { if (exam.languageInspection && !exam.languageInspection.statement) { exam.languageInspection.statement = { comment: '' }; } - this.questionSummary = this.Question.getQuestionAmounts(exam); + this.questionSummary = this.QuestionScore.getQuestionAmounts(exam); this.exam = exam; this.participation = participation; }, @@ -122,7 +122,7 @@ export class AssessmentComponent implements OnInit { scoreSet = (revision: string) => { this.participation._rev = revision; - this.questionSummary = this.Question.getQuestionAmounts(this.exam); + this.questionSummary = this.QuestionScore.getQuestionAmounts(this.exam); this.startReview(); }; diff --git a/ui/src/app/review/assessment/print/printed-assessment.component.ts b/ui/src/app/review/assessment/print/printed-assessment.component.ts index d9f4d69bd..838a700e8 100644 --- a/ui/src/app/review/assessment/print/printed-assessment.component.ts +++ b/ui/src/app/review/assessment/print/printed-assessment.component.ts @@ -11,9 +11,9 @@ import { parseISO, roundToNearestMinutes } from 'date-fns'; import type { ExamEnrolment, ExamParticipation } from 'src/app/enrolment/enrolment.model'; import { Exam } from 'src/app/exam/exam.model'; import { ExamService } from 'src/app/exam/exam.service'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import type { QuestionAmounts } from 'src/app/question/question.model'; import { ClozeTestAnswer } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import type { Reservation } from 'src/app/reservation/reservation.model'; import { AssessmentService } from 'src/app/review/assessment/assessment.service'; import type { User } from 'src/app/session/session.model'; @@ -60,7 +60,7 @@ export class PrintedAssessmentComponent implements OnInit, AfterViewInit { constructor( private route: ActivatedRoute, private http: HttpClient, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, private Exam: ExamService, private CommonExam: CommonExamService, private Assessment: AssessmentService, @@ -94,7 +94,7 @@ export class PrintedAssessmentComponent implements OnInit, AfterViewInit { ), ); - this.questionSummary = this.Question.getQuestionAmounts(exam); + this.questionSummary = this.QuestionScore.getQuestionAmounts(exam); this.exam = exam; this.user = this.Session.getUser(); this.participation = participation; diff --git a/ui/src/app/review/assessment/print/printed-multi-choice.component.ts b/ui/src/app/review/assessment/print/printed-multi-choice.component.ts index 1fbe57bbf..f12bbbba3 100644 --- a/ui/src/app/review/assessment/print/printed-multi-choice.component.ts +++ b/ui/src/app/review/assessment/print/printed-multi-choice.component.ts @@ -5,8 +5,8 @@ import { NgClass, NgStyle } from '@angular/common'; import { Component, Input } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { ExamSectionQuestion } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import { MathJaxDirective } from 'src/app/shared/math/math-jax.directive'; import { isNumber } from 'src/app/shared/miscellaneous/helpers'; @@ -20,37 +20,37 @@ import { isNumber } from 'src/app/shared/miscellaneous/helpers'; export class PrintedMultiChoiceComponent { @Input() sectionQuestion!: ExamSectionQuestion; - constructor(private Question: QuestionService) {} + constructor(private QuestionScore: QuestionScoringService) {} scoreWeightedMultipleChoiceAnswer = (ignoreForcedScore: boolean) => { if (this.sectionQuestion.question.type !== 'WeightedMultipleChoiceQuestion') { return 0; } - return this.Question.scoreWeightedMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); + return this.QuestionScore.scoreWeightedMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); }; scoreMultipleChoiceAnswer = (ignoreForcedScore: boolean) => { if (this.sectionQuestion.question.type !== 'MultipleChoiceQuestion') { return 0; } - return this.Question.scoreMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); + return this.QuestionScore.scoreMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); }; scoreClaimChoiceAnswer = (ignoreForcedScore: boolean) => { if (this.sectionQuestion.question.type !== 'ClaimChoiceQuestion') { return 0; } - return this.Question.scoreClaimChoiceAnswer(this.sectionQuestion, ignoreForcedScore); + return this.QuestionScore.scoreClaimChoiceAnswer(this.sectionQuestion, ignoreForcedScore); }; - calculateWeightedMaxPoints = () => this.Question.calculateWeightedMaxPoints(this.sectionQuestion.options); + calculateWeightedMaxPoints = () => this.QuestionScore.calculateWeightedMaxPoints(this.sectionQuestion.options); calculateMultiChoiceMaxPoints = () => Number.isInteger(this.sectionQuestion.maxScore) ? this.sectionQuestion.maxScore : this.sectionQuestion.maxScore.toFixed(2); - getCorrectClaimChoiceOptionScore = () => this.Question.getCorrectClaimChoiceOptionScore(this.sectionQuestion); + getCorrectClaimChoiceOptionScore = () => this.QuestionScore.getCorrectClaimChoiceOptionScore(this.sectionQuestion); hasForcedScore = () => isNumber(this.sectionQuestion.forcedScore); } diff --git a/ui/src/app/review/assessment/questions/multi-choice-question.component.ts b/ui/src/app/review/assessment/questions/multi-choice-question.component.ts index 487c94f65..6b2134558 100644 --- a/ui/src/app/review/assessment/questions/multi-choice-question.component.ts +++ b/ui/src/app/review/assessment/questions/multi-choice-question.component.ts @@ -9,8 +9,8 @@ import { ActivatedRoute } from '@angular/router'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ToastrService } from 'ngx-toastr'; import { ExamParticipation } from 'src/app/enrolment/enrolment.model'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { ExamSectionQuestion } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import { AssessmentService } from 'src/app/review/assessment/assessment.service'; import { AttachmentService } from 'src/app/shared/attachment/attachment.service'; import { MathJaxDirective } from 'src/app/shared/math/math-jax.directive'; @@ -56,7 +56,7 @@ export class MultiChoiceQuestionComponent implements OnInit { private toast: ToastrService, private Assessment: AssessmentService, private Attachment: AttachmentService, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, ) {} get scoreValue(): number | null { @@ -84,21 +84,21 @@ export class MultiChoiceQuestionComponent implements OnInit { if (this.sectionQuestion.question.type !== 'WeightedMultipleChoiceQuestion') { return 0; } - return this.Question.scoreWeightedMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); + return this.QuestionScore.scoreWeightedMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); }; scoreMultipleChoiceAnswer = (ignoreForcedScore: boolean) => { if (this.sectionQuestion.question.type !== 'MultipleChoiceQuestion') { return 0; } - return this.Question.scoreMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); + return this.QuestionScore.scoreMultipleChoiceAnswer(this.sectionQuestion, ignoreForcedScore); }; scoreClaimChoiceAnswer = (ignoreForcedScore: boolean) => { if (this.sectionQuestion.question.type !== 'ClaimChoiceQuestion') { return 0; } - return this.Question.scoreClaimChoiceAnswer(this.sectionQuestion, ignoreForcedScore); + return this.QuestionScore.scoreClaimChoiceAnswer(this.sectionQuestion, ignoreForcedScore); }; displayMaxScore = () => @@ -106,11 +106,11 @@ export class MultiChoiceQuestionComponent implements OnInit { ? this.sectionQuestion.maxScore : this.sectionQuestion.maxScore.toFixed(2); - calculateWeightedMaxPoints = () => this.Question.calculateWeightedMaxPoints(this.sectionQuestion.options); + calculateWeightedMaxPoints = () => this.QuestionScore.calculateWeightedMaxPoints(this.sectionQuestion.options); - getMinimumOptionScore = () => this.Question.getMinimumOptionScore(this.sectionQuestion); + getMinimumOptionScore = () => this.QuestionScore.getMinimumOptionScore(this.sectionQuestion); - getCorrectClaimChoiceOptionScore = () => this.Question.getCorrectClaimChoiceOptionScore(this.sectionQuestion); + getCorrectClaimChoiceOptionScore = () => this.QuestionScore.getCorrectClaimChoiceOptionScore(this.sectionQuestion); insertForcedScore = () => { if (this.collaborative && this.participation._rev) { diff --git a/ui/src/app/review/assessment/sections/section.component.ts b/ui/src/app/review/assessment/sections/section.component.ts index 9ca7b9216..48621270b 100644 --- a/ui/src/app/review/assessment/sections/section.component.ts +++ b/ui/src/app/review/assessment/sections/section.component.ts @@ -7,8 +7,8 @@ import { TranslateModule } from '@ngx-translate/core'; import { ExamParticipation } from 'src/app/enrolment/enrolment.model'; import type { Exam, ExamSection } from 'src/app/exam/exam.model'; import { ExamService } from 'src/app/exam/exam.service'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; import { ExamSectionQuestion } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; import { ClozeTestComponent } from 'src/app/review/assessment/questions/cloze-test.component'; import { EssayQuestionComponent } from 'src/app/review/assessment/questions/essay-question.component'; import { MultiChoiceQuestionComponent } from 'src/app/review/assessment/questions/multi-choice-question.component'; @@ -34,12 +34,12 @@ export class ExamSectionComponent implements OnInit, AfterViewInit { constructor( private Exam: ExamService, - private Question: QuestionService, + private QuestionScore: QuestionScoringService, private cdr: ChangeDetectorRef, ) {} ngOnInit() { - this.essayQuestionAmounts = this.Question.getEssayQuestionAmountsBySection(this.section); + this.essayQuestionAmounts = this.QuestionScore.getEssayQuestionAmountsBySection(this.section); } ngAfterViewInit() { @@ -48,7 +48,7 @@ export class ExamSectionComponent implements OnInit, AfterViewInit { scoreSet = (revision: string) => { this.scored.emit(revision); - this.essayQuestionAmounts = this.Question.getEssayQuestionAmountsBySection(this.section); + this.essayQuestionAmounts = this.QuestionScore.getEssayQuestionAmountsBySection(this.section); }; // getReviewProgress gathers the questions that have been reviewed by calculating essay answers that have been evaluated plus the rest of the questions. diff --git a/ui/src/app/review/listing/summary/chart-service.ts b/ui/src/app/review/listing/summary/chart-service.ts new file mode 100644 index 000000000..3124db798 --- /dev/null +++ b/ui/src/app/review/listing/summary/chart-service.ts @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium +// +// SPDX-License-Identifier: EUPL-1.2 + +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { + ArcElement, + CategoryScale, + Chart, + Legend, + LinearScale, + LineController, + LineElement, + PieController, + PointElement, + ScatterController, + Tooltip, + TooltipItem, +} from 'chart.js'; +import ChartDataLabels from 'chartjs-plugin-datalabels'; +import { eachDayOfInterval, min, startOfDay } from 'date-fns'; +import { countBy } from 'ramda'; +import { ExamParticipation } from 'src/app/enrolment/enrolment.model'; +import { Exam } from 'src/app/exam/exam.model'; +import { ExamService } from 'src/app/exam/exam.service'; +import { QuestionScoringService } from 'src/app/question/question-scoring.service'; +import { Question } from 'src/app/question/question.model'; +import { ReviewListService } from 'src/app/review/listing/review-list.service'; +import { CommonExamService } from 'src/app/shared/miscellaneous/common-exam.service'; +import { groupBy } from 'src/app/shared/miscellaneous/helpers'; + +@Injectable({ providedIn: 'root' }) +export class ChartService { + constructor( + private Translate: TranslateService, + private QuestionScore: QuestionScoringService, + private ExamService: ExamService, + private CommonExam: CommonExamService, + private ReviewList: ReviewListService, + ) { + Chart.register([ + ArcElement, + PointElement, + LineElement, + CategoryScale, + LinearScale, + LineController, + PieController, + ScatterController, + Legend, + Tooltip, + ]); + } + + getGradeDistributionChart = (context: string, reviews: ExamParticipation[]) => { + const chartColors = ['#97BBCD', '#DCDCDC', '#F7464A', '#46BFBD', '#FDB45C', '#949FB1', '#4D5360']; + const amount = this.Translate.instant('i18n_pieces'); + const { data, labels } = this.calculateGradeDistribution(reviews); + + return new Chart(context, { + type: 'pie', + data: { + labels: labels, + datasets: [ + { + data: data, + backgroundColor: chartColors, + }, + ], + }, + plugins: [ChartDataLabels], + options: { + maintainAspectRatio: false, + plugins: { + title: { display: false }, + tooltip: { enabled: false }, + legend: { display: true, position: 'top' }, + datalabels: { + color: 'white', + font: { weight: 'bold' }, + formatter: (value, context) => { + const points = context.chart.data.datasets[0].data as number[]; + const sum = points.reduce((a, b) => a + b, 0); + return `${value} ${amount} ${((value / sum) * 100).toFixed(2)}%`; + }, + }, + }, + }, + }); + }; + + getQuestionScoreChart = (context: string, reviews: ExamParticipation[]) => { + const data = this.calculateQuestionData(reviews); + return new Chart(context, { + options: { + maintainAspectRatio: false, + }, + type: 'line', + data: { + labels: data.map((d) => d.question), + datasets: [ + { + label: 'max', + data: data.map((d) => d.max), + fill: false, + tension: 0.1, + borderColor: 'orange', + }, + { + label: 'avg', + data: data.map((d) => d.avg), + fill: false, + tension: 0.1, + borderColor: 'blue', + }, + { + label: 'median', + data: data.map((d) => d.median), + fill: false, + tension: 0.1, + borderColor: 'red', + }, + ], + }, + }); + }; + + getApprovalRateChart = (context: string, reviews: ExamParticipation[]) => { + const data = this.calculateQuestionData(reviews); + return new Chart(context, { + options: { + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + }, + }, + }, + type: 'line', + data: { + labels: data.map((d) => d.question), + datasets: [ + { + label: 'max', + data: data.map((d) => d.approvalRate), + fill: false, + tension: 0.1, + borderColor: 'green', + }, + ], + }, + }); + }; + + getExaminationTimeDistributionChart = (context: string, reviews: ExamParticipation[], exam: Exam) => { + const data = this.calculateExaminationTimeValues(reviews, exam); + return new Chart(context, { + options: { + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + ticks: { + callback: (value) => { + if (Math.floor(Number(value)) === Number(value)) { + return value; + } + return 0; + }, + }, + }, + x: { + title: { + display: true, + text: this.Translate.instant('i18n_days_since_period_beginning').toLowerCase(), + }, + }, + }, + }, + type: 'line', + data: { + labels: data.map((d) => d.date), + datasets: [ + { + label: this.Translate.instant('i18n_amount_exams'), + data: data.map((d) => d.amount), + fill: false, + borderColor: '#028a0f', + tension: 0.1, + pointRadius: 0, + }, + ], + }, + }); + }; + + getGradeTimeChart = (context: string, reviews: ExamParticipation[], exam: Exam) => { + return new Chart(context, { + type: 'scatter', + data: { + datasets: [ + { + showLine: false, + pointBackgroundColor: '#F7464A', + data: this.calculateGradeTimeValues(reviews), + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + max: this.ExamService.getMaxScore(exam), + min: 0, + + display: true, + title: { + display: true, + text: this.Translate.instant('i18n_word_points').toLowerCase(), + }, + }, + + x: { + max: exam.duration, + min: 0, + display: true, + title: { + display: true, + text: this.Translate.instant('i18n_word_minutes').toLowerCase(), + }, + }, + }, + plugins: { + legend: { display: false }, + tooltip: { + displayColors: false, + callbacks: { + label: (item: TooltipItem<'scatter'>) => { + const [xLabel, yLabel] = [item.label, item.formattedValue]; + const pointsLabel = this.Translate.instant('i18n_word_points'); + const minutesLabel = this.Translate.instant('i18n_word_minutes'); + return `${pointsLabel}: ${yLabel} ${minutesLabel}: ${xLabel}`; + }, + }, + }, + }, + }, + }); + }; + + private calculateQuestionData = (reviews: ExamParticipation[]) => { + const sectionQuestions = reviews + .map((r) => r.exam) + .flatMap((e) => e.examSections) + .flatMap((es) => es.sectionQuestions); + const mapped = groupBy(sectionQuestions, (sq) => (sq.question.parent as Question).id.toString()); + return Object.entries(mapped) + .map((e) => ({ + question: e[0], + max: this.QuestionScore.calculateMaxScore(e[1][0]), // hope this is ok + scores: e[1].map((sq) => this.QuestionScore.calculateAnswerScore(sq)).filter((s) => s != null), + })) + .map((e) => ({ + question: e.question, + max: e.max, + avg: + e.scores.reduce((a, b) => { + const score = a + (b ? b.score : 0); + return score; + }, 0) / e.scores.length, + median: this.median(...e.scores.map((s) => (s ? s.score : 0))), + approvalRate: e.scores.filter((s) => s?.approved || (s && s.score > 0)).length / e.scores.length, + })); + }; + + private calculateGradeTimeValues = (reviews: ExamParticipation[]) => { + return reviews + .sort( + (a, b) => + this.ReviewList.diffInMinutes(a.started, a.ended) - + this.ReviewList.diffInMinutes(b.started, b.ended), + ) + .map((r) => ({ x: this.ReviewList.diffInMinutes(r.started, r.ended), y: r.exam.totalScore })); + }; + + private calculateExaminationTimeValues = (reviews: ExamParticipation[], exam: Exam) => { + const dates = eachDayOfInterval({ + start: min([new Date(exam.periodStart as string), new Date()]), + end: min([new Date(exam.periodEnd as string), new Date()]), + }); + return dates.map((d, i) => ({ + date: i, + amount: reviews.filter((r) => startOfDay(new Date(r.ended)) <= d).length, + })); + }; + + private calculateGradeDistribution = (reviews: ExamParticipation[]) => { + const grades = this.getGrades(reviews); + const gradeDistribution = countBy((g) => g, grades); + const data = Object.values(gradeDistribution); + const labels = Object.keys(gradeDistribution).map(this.CommonExam.getExamGradeDisplayName); + return { data: data, labels: labels }; + }; + + private getGrades = (reviews: ExamParticipation[]): string[] => + reviews + .filter((r) => r.exam.gradedTime) + .map((r) => (r.exam.grade ? r.exam.grade.name : this.Translate.instant('i18n_no_grading'))); + + private median = (...xs: number[]) => { + const sz = xs.length; + const sorted = xs.sort(); + return sz % 2 == 1 ? sorted[Math.floor(sz / 2)] : (sorted[Math.floor(sz / 2 - 1)] + sorted[sz / 2]) / 2; + }; +} diff --git a/ui/src/app/review/listing/summary/exam-summary.component.ts b/ui/src/app/review/listing/summary/exam-summary.component.ts index b15a95efa..0f1a56d2d 100644 --- a/ui/src/app/review/listing/summary/exam-summary.component.ts +++ b/ui/src/app/review/listing/summary/exam-summary.component.ts @@ -17,6 +17,7 @@ import { NoShowsComponent } from 'src/app/review/listing/dialogs/no-shows.compon import { ReviewListService } from 'src/app/review/listing/review-list.service'; import type { Review } from 'src/app/review/review.model'; import { FileService } from 'src/app/shared/file/file.service'; +import { ChartService } from './chart-service'; import { ExamSummaryService } from './exam-summary.service'; @Component({ @@ -44,6 +45,7 @@ export class ExamSummaryComponent implements OnInit, OnChanges { private route: ActivatedRoute, private translate: TranslateService, private modal: NgbModal, + private ChartService: ChartService, private ExamSummary: ExamSummaryService, private ReviewList: ReviewListService, private Files: FileService, @@ -133,18 +135,18 @@ export class ExamSummaryComponent implements OnInit, OnChanges { private refresh = () => { this.ExamSummary.getNoShows$(this.collaborative, this.exam).subscribe((ns) => (this.noShows = ns)); - this.gradeDistributionChart = this.ExamSummary.getGradeDistributionChart( + this.gradeDistributionChart = this.ChartService.getGradeDistributionChart( 'gradeDistributionChart', this.reviews, ); - this.examinationDateDistribution = this.ExamSummary.getExaminationTimeDistributionChart( + this.examinationDateDistribution = this.ChartService.getExaminationTimeDistributionChart( 'examinationDateDistributionChart', this.reviews, this.exam, ); - this.gradeTimeChart = this.ExamSummary.getGradeTimeChart('gradeTimeChart', this.reviews, this.exam); - this.questionScoreChart = this.ExamSummary.getQuestionScoreChart('questionScoreChart', this.reviews); - this.approvalRatingChart = this.ExamSummary.getApprovalRateChart('approvalRatingChart', this.reviews); + this.gradeTimeChart = this.ChartService.getGradeTimeChart('gradeTimeChart', this.reviews, this.exam); + this.questionScoreChart = this.ChartService.getQuestionScoreChart('questionScoreChart', this.reviews); + this.approvalRatingChart = this.ChartService.getApprovalRateChart('approvalRatingChart', this.reviews); this.gradedCount = this.reviews.filter((r) => r.exam.gradedTime).length; this.abortedExams = this.ReviewList.filterByStateAndEnhance(['ABORTED'], this.reviews, this.collaborative); }; diff --git a/ui/src/app/review/listing/summary/exam-summary.service.ts b/ui/src/app/review/listing/summary/exam-summary.service.ts index 041311fa2..9ff354e67 100644 --- a/ui/src/app/review/listing/summary/exam-summary.service.ts +++ b/ui/src/app/review/listing/summary/exam-summary.service.ts @@ -5,56 +5,18 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { - ArcElement, - CategoryScale, - Chart, - Legend, - LinearScale, - LineController, - LineElement, - PieController, - PointElement, - ScatterController, - Tooltip, - TooltipItem, -} from 'chart.js'; -import ChartDataLabels from 'chartjs-plugin-datalabels'; -import { eachDayOfInterval, min, startOfDay } from 'date-fns'; -import { countBy } from 'ramda'; import { of } from 'rxjs'; import { ExamEnrolment, ExamParticipation } from 'src/app/enrolment/enrolment.model'; import { Exam } from 'src/app/exam/exam.model'; import { ExamService } from 'src/app/exam/exam.service'; -import { Question } from 'src/app/question/question.model'; -import { QuestionService } from 'src/app/question/question.service'; -import { ReviewListService } from 'src/app/review/listing/review-list.service'; -import { CommonExamService } from 'src/app/shared/miscellaneous/common-exam.service'; -import { groupBy } from 'src/app/shared/miscellaneous/helpers'; @Injectable({ providedIn: 'root' }) export class ExamSummaryService { constructor( private http: HttpClient, private translate: TranslateService, - private Question: QuestionService, private Exam: ExamService, - private CommonExam: CommonExamService, - private ReviewList: ReviewListService, - ) { - Chart.register([ - ArcElement, - PointElement, - LineElement, - CategoryScale, - LinearScale, - LineController, - PieController, - ScatterController, - Legend, - Tooltip, - ]); - } + ) {} getNoShows$ = (collaborative: boolean, exam: Exam) => { if (collaborative) { @@ -70,203 +32,6 @@ export class ExamSummaryService { .filter((r) => r.exam.gradedTime) .map((r) => (r.exam.grade ? r.exam.grade.name : this.translate.instant('i18n_no_grading'))); - getGradeDistributionChart = (context: string, reviews: ExamParticipation[]) => { - const chartColors = ['#97BBCD', '#DCDCDC', '#F7464A', '#46BFBD', '#FDB45C', '#949FB1', '#4D5360']; - const amount = this.translate.instant('i18n_pieces'); - const { data, labels } = this.calculateGradeDistribution(reviews); - - return new Chart(context, { - type: 'pie', - data: { - labels: labels, - datasets: [ - { - data: data, - backgroundColor: chartColors, - }, - ], - }, - plugins: [ChartDataLabels], - options: { - maintainAspectRatio: false, - plugins: { - title: { display: false }, - tooltip: { enabled: false }, - legend: { display: true, position: 'top' }, - datalabels: { - color: 'white', - font: { weight: 'bold' }, - formatter: (value, context) => { - const points = context.chart.data.datasets[0].data as number[]; - const sum = points.reduce((a, b) => a + b, 0); - return `${value} ${amount} ${((value / sum) * 100).toFixed(2)}%`; - }, - }, - }, - }, - }); - }; - - getQuestionScoreChart = (context: string, reviews: ExamParticipation[]) => { - const data = this.calculateQuestionData(reviews); - return new Chart(context, { - options: { - maintainAspectRatio: false, - }, - type: 'line', - data: { - labels: data.map((d) => d.question), - datasets: [ - { - label: 'max', - data: data.map((d) => d.max), - fill: false, - tension: 0.1, - borderColor: 'orange', - }, - { - label: 'avg', - data: data.map((d) => d.avg), - fill: false, - tension: 0.1, - borderColor: 'blue', - }, - { - label: 'median', - data: data.map((d) => d.median), - fill: false, - tension: 0.1, - borderColor: 'red', - }, - ], - }, - }); - }; - - getApprovalRateChart = (context: string, reviews: ExamParticipation[]) => { - const data = this.calculateQuestionData(reviews); - return new Chart(context, { - options: { - maintainAspectRatio: false, - scales: { - y: { - beginAtZero: true, - }, - }, - }, - type: 'line', - data: { - labels: data.map((d) => d.question), - datasets: [ - { - label: 'max', - data: data.map((d) => d.approvalRate), - fill: false, - tension: 0.1, - borderColor: 'green', - }, - ], - }, - }); - }; - - getExaminationTimeDistributionChart = (context: string, reviews: ExamParticipation[], exam: Exam) => { - const data = this.calculateExaminationTimeValues(reviews, exam); - return new Chart(context, { - options: { - maintainAspectRatio: false, - scales: { - y: { - beginAtZero: true, - ticks: { - callback: (value) => { - if (Math.floor(Number(value)) === Number(value)) { - return value; - } - return 0; - }, - }, - }, - x: { - title: { - display: true, - text: this.translate.instant('i18n_days_since_period_beginning').toLowerCase(), - }, - }, - }, - }, - type: 'line', - data: { - labels: data.map((d) => d.date), - datasets: [ - { - label: this.translate.instant('i18n_amount_exams'), - data: data.map((d) => d.amount), - fill: false, - borderColor: '#028a0f', - tension: 0.1, - pointRadius: 0, - }, - ], - }, - }); - }; - - getGradeTimeChart = (context: string, reviews: ExamParticipation[], exam: Exam) => { - return new Chart(context, { - type: 'scatter', - data: { - datasets: [ - { - showLine: false, - pointBackgroundColor: '#F7464A', - data: this.calculateGradeTimeValues(reviews), - }, - ], - }, - options: { - responsive: true, - maintainAspectRatio: false, - scales: { - y: { - max: this.Exam.getMaxScore(exam), - min: 0, - - display: true, - title: { - display: true, - text: this.translate.instant('i18n_word_points').toLowerCase(), - }, - }, - - x: { - max: exam.duration, - min: 0, - display: true, - title: { - display: true, - text: this.translate.instant('i18n_word_minutes').toLowerCase(), - }, - }, - }, - plugins: { - legend: { display: false }, - tooltip: { - displayColors: false, - callbacks: { - label: (item: TooltipItem<'scatter'>) => { - const [xLabel, yLabel] = [item.label, item.formattedValue]; - const pointsLabel = this.translate.instant('i18n_word_points'); - const minutesLabel = this.translate.instant('i18n_word_minutes'); - return `${pointsLabel}: ${yLabel} ${minutesLabel}: ${xLabel}`; - }, - }, - }, - }, - }, - }); - }; - calcSectionMaxAndAverages = (reviews: ExamParticipation[], exam: Exam) => { const parentSectionMaxScores: Record = exam.examSections.reduce( (obj, current) => ({ @@ -314,64 +79,4 @@ export class ExamSummaryService { {}, ); }; - - private calculateGradeDistribution = (reviews: ExamParticipation[]) => { - const grades = this.getGrades(reviews); - const gradeDistribution = countBy((g) => g, grades); - const data = Object.values(gradeDistribution); - const labels = Object.keys(gradeDistribution).map(this.CommonExam.getExamGradeDisplayName); - return { data: data, labels: labels }; - }; - - private calculateGradeTimeValues = (reviews: ExamParticipation[]) => { - return reviews - .sort( - (a, b) => - this.ReviewList.diffInMinutes(a.started, a.ended) - - this.ReviewList.diffInMinutes(b.started, b.ended), - ) - .map((r) => ({ x: this.ReviewList.diffInMinutes(r.started, r.ended), y: r.exam.totalScore })); - }; - - private calculateExaminationTimeValues = (reviews: ExamParticipation[], exam: Exam) => { - const dates = eachDayOfInterval({ - start: min([new Date(exam.periodStart as string), new Date()]), - end: min([new Date(exam.periodEnd as string), new Date()]), - }); - return dates.map((d, i) => ({ - date: i, - amount: reviews.filter((r) => startOfDay(new Date(r.ended)) <= d).length, - })); - }; - - private median = (...xs: number[]) => { - const sz = xs.length; - const sorted = xs.sort(); - return sz % 2 == 1 ? sorted[Math.floor(sz / 2)] : (sorted[Math.floor(sz / 2 - 1)] + sorted[sz / 2]) / 2; - }; - - private calculateQuestionData = (reviews: ExamParticipation[]) => { - const sectionQuestions = reviews - .map((r) => r.exam) - .flatMap((e) => e.examSections) - .flatMap((es) => es.sectionQuestions); - const mapped = groupBy(sectionQuestions, (sq) => (sq.question.parent as Question).id.toString()); - return Object.entries(mapped) - .map((e) => ({ - question: e[0], - max: this.Question.calculateMaxScore(e[1][0]), // hope this is ok - scores: e[1].map((sq) => this.Question.calculateAnswerScore(sq)).filter((s) => s != null), - })) - .map((e) => ({ - question: e.question, - max: e.max, - avg: - e.scores.reduce((a, b) => { - const score = a + (b ? b.score : 0); - return score; - }, 0) / e.scores.length, - median: this.median(...e.scores.map((s) => (s ? s.score : 0))), - approvalRate: e.scores.filter((s) => s?.approved || (s && s.score > 0)).length / e.scores.length, - })); - }; }