From 0ddf823f0972deeda9f03a82f7d065d23f1be028 Mon Sep 17 00:00:00 2001 From: Matti Lupari Date: Wed, 22 May 2024 17:45:21 +0300 Subject: [PATCH] CSCEXAM-000 Email composer in Scala --- .../assessment/ExamInspectionController.java | 2 +- .../assessment/ExamRecordController.java | 2 +- .../LanguageInspectionController.java | 2 +- .../assessment/ReviewController.java | 2 +- .../calendar/CalendarController.java | 5 +- .../enrolment/EnrolmentController.java | 5 +- .../enrolment/ReservationController.java | 11 +- app/controllers/exam/ExamController.java | 2 +- .../examination/ExaminationController.java | 2 +- .../impl/CollaborativeCalendarController.java | 2 +- .../impl/CollaborativeExamController.java | 10 +- .../impl/CollaborativeReviewController.java | 2 +- .../impl/ExternalCalendarController.java | 6 +- .../transfer/impl/ExternalExamController.java | 2 +- .../impl/ExternalExaminationController.java | 2 +- .../impl/ExternalReservationHandlerImpl.java | 5 +- app/impl/AutoEvaluationHandlerImpl.scala | 7 +- app/impl/CalendarHandlerImpl.java | 2 +- app/impl/EmailComposer.java | 112 -- app/impl/EmailComposerImpl.java | 1200 ----------------- app/impl/ExamUpdaterImpl.java | 2 +- app/impl/NoShowHandlerImpl.scala | 1 + app/impl/mail/EmailComposer.scala | 96 ++ app/impl/mail/EmailComposerImpl.scala | 781 +++++++++++ app/impl/mail/EmailSender.java | 82 -- app/impl/mail/EmailSender.scala | 40 + app/impl/mail/EmailSenderImpl.java | 87 -- app/impl/mail/EmailSenderImpl.scala | 73 + app/system/SystemInitializer.scala | 2 +- .../actors/AutoEvaluationNotifierActor.scala | 2 +- app/system/actors/ExamAutoSaverActor.scala | 2 +- .../actors/ReservationReminderActor.scala | 2 +- .../plugins/clozetest/dialogs/cloze.js | 2 - .../ckeditor/plugins/clozetest/lang/en.js | 1 - .../ckeditor/plugins/clozetest/lang/fi.js | 1 - .../ckeditor/plugins/clozetest/lang/sv.js | 1 - 36 files changed, 1042 insertions(+), 1516 deletions(-) delete mode 100644 app/impl/EmailComposer.java delete mode 100644 app/impl/EmailComposerImpl.java create mode 100644 app/impl/mail/EmailComposer.scala create mode 100644 app/impl/mail/EmailComposerImpl.scala delete mode 100644 app/impl/mail/EmailSender.java create mode 100644 app/impl/mail/EmailSender.scala delete mode 100644 app/impl/mail/EmailSenderImpl.java create mode 100644 app/impl/mail/EmailSenderImpl.scala diff --git a/app/controllers/assessment/ExamInspectionController.java b/app/controllers/assessment/ExamInspectionController.java index 38b9cd769..b41f17be4 100644 --- a/app/controllers/assessment/ExamInspectionController.java +++ b/app/controllers/assessment/ExamInspectionController.java @@ -7,7 +7,7 @@ import be.objectify.deadbolt.java.actions.Group; import be.objectify.deadbolt.java.actions.Restrict; import controllers.base.BaseController; -import impl.EmailComposer; +import impl.mail.EmailComposer; import io.ebean.DB; import io.ebean.Model; import java.util.Optional; diff --git a/app/controllers/assessment/ExamRecordController.java b/app/controllers/assessment/ExamRecordController.java index f8648cefb..7c6ac2cd8 100644 --- a/app/controllers/assessment/ExamRecordController.java +++ b/app/controllers/assessment/ExamRecordController.java @@ -7,7 +7,7 @@ import be.objectify.deadbolt.java.actions.Group; import be.objectify.deadbolt.java.actions.Restrict; import controllers.base.BaseController; -import impl.EmailComposer; +import impl.mail.EmailComposer; import io.ebean.DB; import java.io.ByteArrayOutputStream; import java.io.File; diff --git a/app/controllers/assessment/LanguageInspectionController.java b/app/controllers/assessment/LanguageInspectionController.java index 93780fd12..a0df26078 100644 --- a/app/controllers/assessment/LanguageInspectionController.java +++ b/app/controllers/assessment/LanguageInspectionController.java @@ -9,7 +9,7 @@ import be.objectify.deadbolt.java.actions.Pattern; import be.objectify.deadbolt.java.actions.Restrict; import controllers.base.BaseController; -import impl.EmailComposer; +import impl.mail.EmailComposer; import io.ebean.DB; import io.ebean.ExpressionList; import io.ebean.FetchConfig; diff --git a/app/controllers/assessment/ReviewController.java b/app/controllers/assessment/ReviewController.java index ebf8a6c25..f3ddce2c1 100644 --- a/app/controllers/assessment/ReviewController.java +++ b/app/controllers/assessment/ReviewController.java @@ -8,7 +8,7 @@ import be.objectify.deadbolt.java.actions.Restrict; import com.fasterxml.jackson.databind.JsonNode; import controllers.base.BaseController; -import impl.EmailComposer; +import impl.mail.EmailComposer; import io.ebean.DB; import io.ebean.ExpressionList; import io.ebean.FetchConfig; diff --git a/app/controllers/calendar/CalendarController.java b/app/controllers/calendar/CalendarController.java index 6402318b2..cd94bb9c0 100644 --- a/app/controllers/calendar/CalendarController.java +++ b/app/controllers/calendar/CalendarController.java @@ -10,7 +10,7 @@ import controllers.iop.transfer.api.ExternalReservationHandler; import exceptions.NotFoundException; import impl.CalendarHandler; -import impl.EmailComposer; +import impl.mail.EmailComposer; import io.ebean.DB; import io.ebean.Transaction; import java.util.Collection; @@ -40,6 +40,7 @@ import sanitizers.Attrs; import sanitizers.CalendarReservationSanitizer; import scala.concurrent.duration.Duration; +import scala.jdk.javaapi.OptionConverters; import security.Authenticated; public class CalendarController extends BaseController { @@ -101,7 +102,7 @@ public Result removeReservation(long id, Http.Request request) throws NotFoundEx emailComposer.composeReservationCancellationNotification( enrolment.getUser(), reservation, - "", + OptionConverters.toScala(Optional.empty()), isStudentUser, enrolment ); diff --git a/app/controllers/enrolment/EnrolmentController.java b/app/controllers/enrolment/EnrolmentController.java index 234595f24..8fb273181 100644 --- a/app/controllers/enrolment/EnrolmentController.java +++ b/app/controllers/enrolment/EnrolmentController.java @@ -8,8 +8,8 @@ import be.objectify.deadbolt.java.actions.Restrict; import controllers.base.BaseController; import controllers.iop.transfer.api.ExternalReservationHandler; -import impl.EmailComposer; import impl.ExternalCourseHandler; +import impl.mail.EmailComposer; import io.ebean.DB; import io.ebean.Transaction; import java.io.IOException; @@ -644,13 +644,14 @@ public Result removeExaminationEvent(Long configId) { }); config.delete(); event.delete(); + var users = enrolments.stream().map(ExamEnrolment::getUser).collect(Collectors.toSet()); actor .scheduler() .scheduleOnce( Duration.create(1, TimeUnit.SECONDS), () -> { emailComposer.composeExaminationEventCancellationNotification( - enrolments.stream().map(ExamEnrolment::getUser).collect(Collectors.toSet()), + CollectionConverters.asScala(users).toSet(), exam, event ); diff --git a/app/controllers/enrolment/ReservationController.java b/app/controllers/enrolment/ReservationController.java index 551bc818f..13d1f5371 100644 --- a/app/controllers/enrolment/ReservationController.java +++ b/app/controllers/enrolment/ReservationController.java @@ -12,7 +12,7 @@ import controllers.iop.collaboration.api.CollaborativeExamLoader; import controllers.iop.transfer.api.ExternalReservationHandler; import exceptions.NotFoundException; -import impl.EmailComposer; +import impl.mail.EmailComposer; import io.ebean.DB; import io.ebean.FetchConfig; import io.ebean.text.PathProperties; @@ -39,6 +39,7 @@ import play.mvc.Http; import play.mvc.Result; import sanitizers.Attrs; +import scala.jdk.javaapi.OptionConverters; import security.Authenticated; import system.interceptors.Anonymous; @@ -147,7 +148,13 @@ public CompletionStage removeReservation(long id, Http.Request request) // Let's not send emails about historical reservations if (reservation.getEndAt().isAfter(DateTime.now())) { var student = enrolment.getUser(); - emailComposer.composeReservationCancellationNotification(student, reservation, msg, false, enrolment); + emailComposer.composeReservationCancellationNotification( + student, + reservation, + OptionConverters.toScala(Optional.of(msg)), + false, + enrolment + ); } if (reservation.getExternalReservation() != null) { diff --git a/app/controllers/exam/ExamController.java b/app/controllers/exam/ExamController.java index 539d2f519..ebc79170b 100644 --- a/app/controllers/exam/ExamController.java +++ b/app/controllers/exam/ExamController.java @@ -9,8 +9,8 @@ import be.objectify.deadbolt.java.actions.Restrict; import com.fasterxml.jackson.databind.node.ObjectNode; import controllers.base.BaseController; -import impl.EmailComposer; import impl.ExamUpdater; +import impl.mail.EmailComposer; import io.ebean.DB; import io.ebean.ExpressionList; import io.ebean.FetchConfig; diff --git a/app/controllers/examination/ExaminationController.java b/app/controllers/examination/ExaminationController.java index 29f5480a1..4006c66d9 100644 --- a/app/controllers/examination/ExaminationController.java +++ b/app/controllers/examination/ExaminationController.java @@ -12,7 +12,7 @@ import controllers.base.BaseController; import controllers.iop.transfer.api.ExternalAttachmentLoader; import impl.AutoEvaluationHandler; -import impl.EmailComposer; +import impl.mail.EmailComposer; import io.ebean.DB; import io.ebean.text.PathProperties; import java.io.IOException; diff --git a/app/controllers/iop/collaboration/impl/CollaborativeCalendarController.java b/app/controllers/iop/collaboration/impl/CollaborativeCalendarController.java index fcbd957de..cddfec351 100644 --- a/app/controllers/iop/collaboration/impl/CollaborativeCalendarController.java +++ b/app/controllers/iop/collaboration/impl/CollaborativeCalendarController.java @@ -8,7 +8,7 @@ import be.objectify.deadbolt.java.actions.Group; import be.objectify.deadbolt.java.actions.Restrict; import impl.CalendarHandler; -import impl.EmailComposer; +import impl.mail.EmailComposer; import io.ebean.DB; import io.ebean.Transaction; import io.ebean.text.PathProperties; diff --git a/app/controllers/iop/collaboration/impl/CollaborativeExamController.java b/app/controllers/iop/collaboration/impl/CollaborativeExamController.java index 19ca92208..39a9628d9 100644 --- a/app/controllers/iop/collaboration/impl/CollaborativeExamController.java +++ b/app/controllers/iop/collaboration/impl/CollaborativeExamController.java @@ -8,7 +8,7 @@ import be.objectify.deadbolt.java.actions.Group; import be.objectify.deadbolt.java.actions.Restrict; import com.fasterxml.jackson.databind.JsonNode; -import impl.EmailComposer; +import impl.mail.EmailComposer; import io.ebean.DB; import java.net.URL; import java.util.List; @@ -41,6 +41,7 @@ import sanitizers.EmailSanitizer; import sanitizers.ExamUpdateSanitizer; import scala.concurrent.duration.Duration; +import scala.jdk.javaapi.CollectionConverters; import security.Authenticated; public class CollaborativeExamController extends CollaborationController { @@ -237,7 +238,12 @@ public CompletionStage updateExam(Long id, Http.Request request) { .scheduler() .scheduleOnce( Duration.create(1, TimeUnit.SECONDS), - () -> composer.composeCollaborativeExamAnnouncement(receivers, user, exam), + () -> + composer.composeCollaborativeExamAnnouncement( + CollectionConverters.asScala(receivers).toSet(), + user, + exam + ), as.dispatcher() ); } diff --git a/app/controllers/iop/collaboration/impl/CollaborativeReviewController.java b/app/controllers/iop/collaboration/impl/CollaborativeReviewController.java index fbdc1b96a..83bb8214d 100644 --- a/app/controllers/iop/collaboration/impl/CollaborativeReviewController.java +++ b/app/controllers/iop/collaboration/impl/CollaborativeReviewController.java @@ -10,7 +10,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import impl.EmailComposer; +import impl.mail.EmailComposer; import io.ebean.DB; import io.vavr.control.Either; import java.io.File; diff --git a/app/controllers/iop/transfer/impl/ExternalCalendarController.java b/app/controllers/iop/transfer/impl/ExternalCalendarController.java index 93b519b93..7df4d07d7 100644 --- a/app/controllers/iop/transfer/impl/ExternalCalendarController.java +++ b/app/controllers/iop/transfer/impl/ExternalCalendarController.java @@ -54,6 +54,7 @@ import play.mvc.With; import sanitizers.Attrs; import sanitizers.ExternalCalendarReservationSanitizer; +import scala.jdk.javaapi.OptionConverters; import security.Authenticated; public class ExternalCalendarController extends CalendarController { @@ -370,7 +371,10 @@ public CompletionStage requestReservationRevocation(String ref, Http.Req return internalServerError(root.get("message").asText("Connection refused")); } String msg = request.body().asJson().path("msg").asText(""); - emailComposer.composeExternalReservationCancellationNotification(reservation, msg); + emailComposer.composeExternalReservationCancellationNotification( + reservation, + OptionConverters.toScala(Optional.of(msg)) + ); reservation.delete(); return ok(); }; diff --git a/app/controllers/iop/transfer/impl/ExternalExamController.java b/app/controllers/iop/transfer/impl/ExternalExamController.java index 6a34827fc..8616209c2 100644 --- a/app/controllers/iop/transfer/impl/ExternalExamController.java +++ b/app/controllers/iop/transfer/impl/ExternalExamController.java @@ -15,8 +15,8 @@ import controllers.iop.transfer.api.ExternalAttachmentLoader; import controllers.iop.transfer.api.ExternalExamAPI; import impl.AutoEvaluationHandler; -import impl.EmailComposer; import impl.NoShowHandler; +import impl.mail.EmailComposer; import io.ebean.DB; import io.ebean.Query; import io.ebean.text.PathProperties; diff --git a/app/controllers/iop/transfer/impl/ExternalExaminationController.java b/app/controllers/iop/transfer/impl/ExternalExaminationController.java index 4b6a423b7..e240295ac 100644 --- a/app/controllers/iop/transfer/impl/ExternalExaminationController.java +++ b/app/controllers/iop/transfer/impl/ExternalExaminationController.java @@ -12,7 +12,7 @@ import controllers.examination.ExaminationController; import controllers.iop.transfer.api.ExternalAttachmentLoader; import impl.AutoEvaluationHandler; -import impl.EmailComposer; +import impl.mail.EmailComposer; import io.ebean.DB; import io.ebean.text.PathProperties; import io.vavr.Tuple; diff --git a/app/controllers/iop/transfer/impl/ExternalReservationHandlerImpl.java b/app/controllers/iop/transfer/impl/ExternalReservationHandlerImpl.java index 13be74f9f..aaa2df67e 100644 --- a/app/controllers/iop/transfer/impl/ExternalReservationHandlerImpl.java +++ b/app/controllers/iop/transfer/impl/ExternalReservationHandlerImpl.java @@ -7,7 +7,7 @@ import com.fasterxml.jackson.databind.JsonNode; import controllers.iop.transfer.api.ExternalReservationHandler; -import impl.EmailComposer; +import impl.mail.EmailComposer; import io.ebean.DB; import java.io.IOException; import java.net.MalformedURLException; @@ -36,6 +36,7 @@ import play.mvc.Result; import play.mvc.Results; import scala.concurrent.duration.Duration; +import scala.jdk.javaapi.OptionConverters; public class ExternalReservationHandlerImpl implements ExternalReservationHandler { @@ -128,7 +129,7 @@ private CompletionStage requestRemoval(String ref, User user, String msg emailComposer.composeReservationCancellationNotification( enrolment.getUser(), reservation, - msg, + OptionConverters.toScala(Optional.of(msg)), isStudentUser, enrolment ); diff --git a/app/impl/AutoEvaluationHandlerImpl.scala b/app/impl/AutoEvaluationHandlerImpl.scala index 8b4a0cccc..96c56c25b 100644 --- a/app/impl/AutoEvaluationHandlerImpl.scala +++ b/app/impl/AutoEvaluationHandlerImpl.scala @@ -4,8 +4,9 @@ package impl -import models.assessment.{AutoEvaluationConfig, GradeEvaluation} -import models.exam.{Exam, Grade, GradeScale} +import impl.mail.EmailComposer +import models.assessment.AutoEvaluationConfig +import models.exam.{Exam, GradeScale} import org.apache.pekko.actor.ActorSystem import org.joda.time.DateTime import play.api.Logging @@ -14,7 +15,7 @@ import java.util.concurrent.TimeUnit import javax.inject.Inject import scala.concurrent.ExecutionContext import scala.concurrent.duration.Duration -import scala.jdk.CollectionConverters._ +import scala.jdk.CollectionConverters.* class AutoEvaluationHandlerImpl @Inject ( private val composer: EmailComposer, diff --git a/app/impl/CalendarHandlerImpl.java b/app/impl/CalendarHandlerImpl.java index c134b8059..7387106b3 100644 --- a/app/impl/CalendarHandlerImpl.java +++ b/app/impl/CalendarHandlerImpl.java @@ -1,5 +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 @@ -10,6 +9,7 @@ import controllers.admin.SettingsController; import controllers.iop.transfer.api.ExternalReservationHandler; import exceptions.NotFoundException; +import impl.mail.EmailComposer; import io.ebean.DB; import java.util.ArrayList; import java.util.Collection; diff --git a/app/impl/EmailComposer.java b/app/impl/EmailComposer.java deleted file mode 100644 index e6ced6603..000000000 --- a/app/impl/EmailComposer.java +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// -// SPDX-License-Identifier: EUPL-1.2 - -package impl; - -import com.google.inject.ImplementedBy; -import java.util.Set; -import models.assessment.LanguageInspection; -import models.enrolment.ExamEnrolment; -import models.enrolment.ExaminationEvent; -import models.enrolment.Reservation; -import models.exam.Exam; -import models.facility.ExamMachine; -import models.iop.CollaborativeExam; -import models.user.User; - -@ImplementedBy(value = EmailComposerImpl.class) -public interface EmailComposer { - /** - * Message sent to student when review is ready. - */ - void composeInspectionReady(User student, User reviewer, Exam exam); - - /** - * Message sent to other inspectors when review is ready. - */ - void composeInspectionMessage(User inspector, User sender, Exam exam, String msg); - void composeInspectionMessage(User inspector, User sender, CollaborativeExam ce, Exam exam, String msg); - - /** - * Weekly summary report - */ - void composeWeeklySummary(User teacher); - - /** - * Message sent to student when reservation has been made. - */ - void composeReservationNotification(User student, Reservation reservation, Exam exam, Boolean isReminder); - - /** - * Message sent to student when examination event has been selected. - */ - void composeExaminationEventNotification(User student, ExamEnrolment enrolment, Boolean isReminder); - - /** - * Message sent to student when examination event has been cancelled. - */ - void composeExaminationEventCancellationNotification(User user, Exam exam, ExaminationEvent event); - void composeExaminationEventCancellationNotification(Set users, Exam exam, ExaminationEvent event); - - /** - * Message sent to newly added inspectors. - */ - void composeExamReviewRequest(User toUser, User fromUser, Exam exam, String message); - - /** - * Message sent to student when reservation has been cancelled. - */ - void composeReservationCancellationNotification( - User student, - Reservation reservation, - String message, - Boolean isStudentUser, - ExamEnrolment enrolment - ); - - /** - * Message sent to student when externally made reservation has been cancelled by hosting admin. - */ - void composeExternalReservationCancellationNotification(Reservation reservation, String message); - - /** - * Message sent to student when reservation has been changed. - */ - void composeReservationChangeNotification( - User student, - ExamMachine previous, - ExamMachine current, - ExamEnrolment enrolment - ); - - /** - * Message sent to student when he/she has been enrolled to a private exam. - */ - void composePrivateExamParticipantNotification(User student, User fromUser, Exam exam); - - /** - * Message sent to teacher when student has finished a private exam. - */ - void composePrivateExamEnded(User toUser, Exam exam); - - /** - * Message sent to teacher when student did not show up for a private exam. - */ - void composeNoShowMessage(User toUser, User student, Exam exam); - - /** - * Message sent to student when he did not show up for exam. - */ - void composeNoShowMessage(User student, String examName, String courseCode); - - /** - * Message sent to teacher when language inspection is finished. - */ - void composeLanguageInspectionFinishedMessage(User toUser, User inspector, LanguageInspection inspection); - - /** - * Message sent to teacher when collaborative exam is created in the system. - */ - void composeCollaborativeExamAnnouncement(Set emails, User sender, Exam exam); -} diff --git a/app/impl/EmailComposerImpl.java b/app/impl/EmailComposerImpl.java deleted file mode 100644 index 139f4db63..000000000 --- a/app/impl/EmailComposerImpl.java +++ /dev/null @@ -1,1200 +0,0 @@ -// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// -// SPDX-License-Identifier: EUPL-1.2 - -package impl; - -import biweekly.Biweekly; -import biweekly.ICalVersion; -import biweekly.ICalendar; -import biweekly.component.VEvent; -import biweekly.property.Summary; -import impl.mail.EmailSender; -import impl.mail.EmailSender.Mail; -import io.ebean.DB; -import io.vavr.Tuple2; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Set; -import java.util.SortedSet; -import java.util.TreeSet; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import javax.annotation.Nonnull; -import javax.inject.Inject; -import miscellaneous.config.ByodConfigHandler; -import miscellaneous.config.ConfigReader; -import miscellaneous.file.FileHandler; -import models.assessment.ExamInspection; -import models.assessment.LanguageInspection; -import models.enrolment.ExamEnrolment; -import models.enrolment.ExamParticipation; -import models.enrolment.ExaminationEvent; -import models.enrolment.ExaminationEventConfiguration; -import models.enrolment.ExternalReservation; -import models.enrolment.Reservation; -import models.exam.Course; -import models.exam.Exam; -import models.exam.ExamExecutionType; -import models.facility.ExamMachine; -import models.facility.MailAddress; -import models.iop.CollaborativeExam; -import models.user.Language; -import models.user.User; -import org.apache.commons.mail.EmailAttachment; -import org.joda.time.DateTime; -import org.joda.time.DateTimeZone; -import org.joda.time.format.DateTimeFormat; -import org.joda.time.format.DateTimeFormatter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import play.Environment; -import play.i18n.Lang; -import play.i18n.MessagesApi; - -class EmailComposerImpl implements EmailComposer { - - private static final String TAG_OPEN = "{{"; - private static final String TAG_CLOSE = "}}"; - private static final DateTimeFormatter DTF = DateTimeFormat.forPattern("dd.MM.yyyy HH:mm ZZZ"); - private static final DateTimeFormatter DF = DateTimeFormat.forPattern("dd.MM.yyyy"); - private static final DateTimeFormatter TF = DateTimeFormat.forPattern("HH:mm"); - private static final int MINUTES_IN_HOUR = 60; - - private final Logger logger = LoggerFactory.getLogger(EmailComposerImpl.class); - - private final String hostName; - private final DateTimeZone timeZone; - private final String baseSystemUrl; - private final String systemAccount; - - private final EmailSender emailSender; - private final FileHandler fileHandler; - private final Environment env; - private final MessagesApi messaging; - private final ByodConfigHandler byodConfigHandler; - - @Inject - EmailComposerImpl( - EmailSender sender, - FileHandler fileHandler, - Environment environment, - MessagesApi messagesApi, - ConfigReader configReader, - ByodConfigHandler byodConfigHandler - ) { - emailSender = sender; - this.fileHandler = fileHandler; - env = environment; - messaging = messagesApi; - hostName = configReader.getHostName(); - timeZone = configReader.getDefaultTimeZone(); - systemAccount = configReader.getSystemAccount(); - baseSystemUrl = configReader.getBaseSystemUrl(); - this.byodConfigHandler = byodConfigHandler; - } - - private String getTemplatesRoot() { - return String.format("%s/conf/template/email/", env.rootPath().getAbsolutePath()); - } - - /** - * This notification is sent to student, when teacher has reviewed the exam - */ - @Override - public void composeInspectionReady(User student, User reviewer, Exam exam) { - String templatePath = getTemplatesRoot() + "reviewReady.html"; - String template = fileHandler.read(templatePath); - Lang lang = getLang(student); - String subject = messaging.get(lang, "email.inspection.ready.subject"); - String examInfo = String.format("%s, %s", exam.getName(), exam.getCourse().getCode().split("_")[0]); - String reviewLink = String.format("%s/participations", hostName); - - Map stringValues = new HashMap<>(); - stringValues.put("review_done", messaging.get(lang, "email.template.review.ready", examInfo)); - stringValues.put("review_link", reviewLink); - stringValues.put("review_link_text", messaging.get(lang, "email.template.link.to.review")); - stringValues.put("main_system_info", messaging.get(lang, "email.template.main.system.info")); - stringValues.put("main_system_url", baseSystemUrl); - - if (reviewer == null && exam.getAutoEvaluationConfig() != null) { - // graded automatically - stringValues.put("review_autoevaluated", messaging.get(lang, "email.template.review.autoevaluated")); - } else { - stringValues.put("review_autoevaluated", null); - } - - //Replace template strings - template = replaceAll(template, stringValues); - - //Send notification - String senderEmail = reviewer != null ? reviewer.getEmail() : systemAccount; - Mail mail = new Mail().recipient(student.getEmail()).sender(senderEmail).subject(subject).content(template); - emailSender.send(mail); - } - - private void sendInspectionMessage( - String link, - String teacher, - String exam, - String msg, - User recipient, - User sender - ) { - Lang lang = getLang(recipient); - Map stringValues = new HashMap<>(); - stringValues.put("teacher_review_done", messaging.get(lang, "email.template.inspection.done", teacher)); - stringValues.put("inspection_comment_title", messaging.get(lang, "email.template.inspection.comment")); - stringValues.put("inspection_link_text", messaging.get(lang, "email.template.link.to.review")); - stringValues.put("exam_info", exam); - stringValues.put("inspection_link", link); - stringValues.put("inspection_comment", msg); - - //Replace template strings - String templatePath = getTemplatesRoot() + "inspectionReady.html"; - String template = fileHandler.read(templatePath); - template = replaceAll(template, stringValues); - - String subject = messaging.get(lang, "email.inspection.comment.subject"); - //Send notification - Mail mail = new Mail() - .recipient(recipient.getEmail()) - .sender(sender.getEmail()) - .subject(subject) - .content(template); - emailSender.send(mail); - } - - /** - * This notification is sent to the creator of exam when assigned inspector has finished inspection - */ - @Override - public void composeInspectionMessage(User inspector, User sender, CollaborativeExam ce, Exam exam, String msg) { - String teacherName = String.format( - "%s %s <%s>", - sender.getFirstName(), - sender.getLastName(), - sender.getEmail() - ); - String examInfo = exam.getName(); - String linkToInspection = String.format( - "%s/staff/assessments/%d/collaborative/%s", - hostName, - ce.getId(), - ce.getRevision() - ); - sendInspectionMessage(linkToInspection, teacherName, examInfo, msg, inspector, sender); - } - - /** - * This notification is sent to the creator of exam when assigned inspector has finished inspection - */ - @Override - public void composeInspectionMessage(User inspector, User sender, Exam exam, String msg) { - String teacherName = String.format( - "%s %s <%s>", - sender.getFirstName(), - sender.getLastName(), - sender.getEmail() - ); - String examInfo = String.format("%s (%s)", exam.getName(), exam.getCourse().getName()); - String linkToInspection = String.format("%s/staff/assessments/%d", hostName, exam.getId()); - - sendInspectionMessage(linkToInspection, teacherName, examInfo, msg, inspector, sender); - } - - private static class ReviewStats implements Comparable { - - int amount; - DateTime earliestDeadLine; - - @Override - public int compareTo(@Nonnull ReviewStats o) { - return earliestDeadLine.compareTo(o.earliestDeadLine); - } - } - - @Override - public void composeWeeklySummary(User teacher) { - Lang lang = getLang(teacher); - String enrolmentBlock = createEnrolmentBlock(teacher, lang); - List reviews = getReviews(teacher); - if (enrolmentBlock.isEmpty() && reviews.isEmpty()) { - // Nothing useful to send - return; - } - logger.info("Sending weekly report to: {}", teacher.getEmail()); - String templatePath = getTemplatesRoot() + "weeklySummary/weeklySummary.html"; - String inspectionTemplatePath = getTemplatesRoot() + "weeklySummary/inspectionInfoSimple.html"; - String template = fileHandler.read(templatePath); - String inspectionTemplate = fileHandler.read(inspectionTemplatePath); - String subject = messaging.get(lang, "email.weekly.report.subject"); - - int totalUngradedExams = reviews.size(); - - Map examReviewMap = new HashMap<>(); - for (ExamParticipation review : reviews) { - Exam exam = review.getExam().getParent(); - ReviewStats stats = examReviewMap.get(exam); - if (stats == null) { - stats = new ReviewStats(); - } - stats.amount++; - if (stats.earliestDeadLine == null || review.getDeadline().isBefore(stats.earliestDeadLine)) { - stats.earliestDeadLine = review.getDeadline(); - } - examReviewMap.put(exam, stats); - } - SortedSet> sorted = sortByValue(examReviewMap); - StringBuilder rowBuilder = new StringBuilder(); - sorted - .stream() - .filter(e -> e.getValue().amount > 0) - .forEach(e -> { - Map stringValues = new HashMap<>(); - stringValues.put("exam_link", String.format("%s/staff/exams/%d/4", hostName, e.getKey().getId())); - stringValues.put("exam_name", e.getKey().getName()); - Course course = e.getKey().getCourse(); - stringValues.put("course_code", course == null ? "" : course.getCode().split("_")[0]); - String summary = messaging.get( - lang, - "email.weekly.report.review.summary", - Integer.toString(e.getValue().amount), - DF.print(new DateTime(e.getValue().earliestDeadLine)) - ); - stringValues.put("review_summary", summary); - String row = replaceAll(inspectionTemplate, stringValues); - rowBuilder.append(row); - }); - - Map stringValues = new HashMap<>(); - stringValues.put("enrolments_title", messaging.get(lang, "email.template.weekly.report.enrolments")); - stringValues.put("enrolment_info_title", messaging.get(lang, "email.template.weekly.report.enrolments.info")); - stringValues.put("enrolment_info", enrolmentBlock.isEmpty() ? "N/A" : enrolmentBlock); - stringValues.put("inspections_title", messaging.get(lang, "email.template.weekly.report.inspections")); - stringValues.put( - "inspections_info", - messaging.get(lang, "email.template.weekly.report.inspections.info", totalUngradedExams) - ); - stringValues.put("inspection_info_own", rowBuilder.toString().isEmpty() ? "N/A" : rowBuilder.toString()); - - String content = replaceAll(template, stringValues); - Mail mail = new Mail().recipient(teacher.getEmail()).sender(systemAccount).subject(subject).content(content); - emailSender.send(mail); - } - - @Override - public void composeExaminationEventNotification(User recipient, ExamEnrolment enrolment, Boolean isReminder) { - String templatePath = getTemplatesRoot() + "examinationEventConfirmed.html"; - String template = fileHandler.read(templatePath); - Exam exam = enrolment.getExam(); - ExaminationEventConfiguration config = enrolment.getExaminationEventConfiguration(); - Lang lang = getLang(recipient); - String subject = String.format( - "%s: \"%s\"", - messaging.get( - lang, - isReminder ? "email.examinationEvent.reminder.subject" : "email.examinationEvent.subject" - ), - exam.getName() - ); - String examInfo = String.format( - "%s %s", - exam.getName(), - exam.getCourse() != null ? String.format("(%s)", exam.getCourse().getCode().split("_")[0]) : "" - ); - String teacherName = getTeachers(exam); - String startDate = DTF.print(new DateTime(config.getExaminationEvent().getStart(), timeZone)); - String examDuration = String.format( - "%dh %dmin", - exam.getDuration() / MINUTES_IN_HOUR, - exam.getDuration() % MINUTES_IN_HOUR - ); - String description = config.getExaminationEvent().getDescription(); - String title = messaging.get(lang, "email.examinationEvent.title"); - - Map stringValues = new HashMap<>(); - stringValues.put("title", title); - stringValues.put("exam_info", messaging.get(lang, "email.template.reservation.exam", examInfo)); - stringValues.put("teacher_name", messaging.get(lang, "email.template.reservation.teacher", teacherName)); - stringValues.put("event_date", messaging.get(lang, "email.examinationEvent.date", startDate)); - stringValues.put( - "exam_duration", - messaging.get(lang, "email.template.reservation.exam.duration", examDuration) - ); - stringValues.put("description", description); - - stringValues.put("cancellation_info", messaging.get(lang, "email.examinationEvent.cancel.info")); - stringValues.put("cancellation_link", hostName); - stringValues.put("cancellation_link_text", messaging.get(lang, "email.examinationEvent.cancel.link.text")); - stringValues.put( - "settings_file_info", - exam.getImplementation() == Exam.Implementation.CLIENT_AUTH - ? String.format("

%s

", messaging.get(lang, "email.examinationEvent.file.info")) - : "" - ); - String content = replaceAll(template, stringValues); - - if (exam.getImplementation() == Exam.Implementation.CLIENT_AUTH) { - // Attach a SEB config file - String quitPassword = byodConfigHandler.getPlaintextPassword( - config.getEncryptedQuitPassword(), - config.getQuitPasswordSalt() - ); - String fileName = exam.getName().replace(" ", "-"); - File file; - try { - file = File.createTempFile(fileName, ".seb"); - FileOutputStream fos = new FileOutputStream(file); - byte[] data = byodConfigHandler.getExamConfig( - config.getHash(), - config.getEncryptedSettingsPassword(), - config.getSettingsPasswordSalt(), - quitPassword - ); - fos.write(data); - fos.close(); - } catch (Exception e) { - logger.error("Failed to create a temporary SEB file on disk!"); - throw new RuntimeException(e); - } - EmailAttachment attachment = new EmailAttachment(); - attachment.setPath(file.getAbsolutePath()); - attachment.setDisposition(EmailAttachment.ATTACHMENT); - attachment.setName(fileName + ".seb"); - if (env.isDev()) { - logger.info("Wrote SEB config file to {}", file.getAbsolutePath()); - } - Mail mail = new Mail() - .recipient(recipient.getEmail()) - .sender(systemAccount) - .subject(subject) - .content(content) - .attachment(attachment); - emailSender.send(mail); - } else { - Mail mail = new Mail() - .recipient(recipient.getEmail()) - .sender(systemAccount) - .subject(subject) - .content(content); - emailSender.send(mail); - } - } - - private String generateExaminationEventCancellationMail( - Exam exam, - ExaminationEvent event, - Lang lang, - boolean isForced - ) { - String templatePath = getTemplatesRoot() + "examinationEventCancelled.html"; - String template = fileHandler.read(templatePath); - - Map stringValues = new HashMap<>(); - String time = DTF.print(adjustDST(event.getStart())); - String teacherName = getTeachers(exam); - String examInfo = String.format( - "%s %s", - exam.getName(), - exam.getCourse() != null ? String.format("(%s)", exam.getCourse().getCode().split("_")[0]) : "" - ); - - stringValues.put("exam", messaging.get(lang, "email.template.reservation.exam", examInfo)); - stringValues.put("teacher", messaging.get(lang, "email.template.reservation.teacher", teacherName)); - stringValues.put("time", messaging.get(lang, "email.examinationEvent.date", time)); - stringValues.put("link", hostName); - stringValues.put( - "message", - messaging.get( - lang, - isForced - ? "email.examinationEvent.cancel.message.admin" - : "email.examinationEvent.cancel.message.student" - ) - ); - stringValues.put("new_time", messaging.get(lang, "email.examinationEvent.cancel.message.student.new.time")); - stringValues.put("description", event.getDescription()); - return replaceAll(template, stringValues); - } - - @Override - public void composeExaminationEventCancellationNotification(Set users, Exam exam, ExaminationEvent event) { - users.forEach(user -> { - Lang lang = getLang(user); - String content = generateExaminationEventCancellationMail(exam, event, lang, true); - String subject = messaging.get(lang, "email.examinationEvent.cancel.subject"); - // email.examinationEvent.cancel.message.admin - Mail mail = new Mail().recipient(user.getEmail()).sender(systemAccount).subject(subject).content(content); - emailSender.send(mail); - }); - } - - public void composeExaminationEventCancellationNotification(User user, Exam exam, ExaminationEvent event) { - Lang lang = getLang(user); - String content = generateExaminationEventCancellationMail(exam, event, lang, false); - String subject = messaging.get(lang, "email.examinationEvent.cancel.subject"); - Mail mail = new Mail().recipient(user.getEmail()).sender(systemAccount).subject(subject).content(content); - emailSender.send(mail); - } - - @Override - public void composeReservationNotification(User recipient, Reservation reservation, Exam exam, Boolean isReminder) { - String templatePath = getTemplatesRoot() + "reservationConfirmed.html"; - String template = fileHandler.read(templatePath); - Lang lang = getLang(recipient); - String subject = String.format( - "%s: \"%s\"", - messaging.get( - lang, - isReminder ? "email.machine.reservation.reminder.subject" : "email.machine.reservation.subject" - ), - exam.getName() - ); - String examInfo = String.format( - "%s %s", - exam.getName(), - exam.getCourse() != null ? String.format("(%s)", exam.getCourse().getCode().split("_")[0]) : "" - ); - String teacherName; - - if (!exam.getExamOwners().isEmpty()) { - teacherName = getTeachers(exam); - } else { - teacherName = String.format("%s %s", exam.getCreator().getFirstName(), exam.getCreator().getLastName()); - } - - DateTime startDate = adjustDST(reservation.getStartAt()); - DateTime endDate = adjustDST(reservation.getEndAt()); - String reservationDate = DTF.print(startDate) + " - " + DTF.print(endDate); - String examDuration = String.format( - "%dh %dmin", - exam.getDuration() / MINUTES_IN_HOUR, - exam.getDuration() % MINUTES_IN_HOUR - ); - - ExamMachine machine = reservation.getMachine(); - ExternalReservation er = reservation.getExternalReservation(); - String machineName = forceNotNull(er == null ? machine.getName() : er.getMachineName()); - String buildingInfo = forceNotNull(er == null ? machine.getRoom().getBuildingName() : er.getBuildingName()); - String roomInstructions; - if (er == null) { - roomInstructions = forceNotNull(machine.getRoom().getRoomInstructions(lang)); - } else { - roomInstructions = forceNotNull(er.getRoomInstructions(lang)); - } - String roomName = forceNotNull(er == null ? machine.getRoom().getName() : er.getRoomName()); - - String title = messaging.get(lang, "email.template.reservation.new"); - - Map stringValues = new HashMap<>(); - stringValues.put("title", title); - stringValues.put("exam_info", messaging.get(lang, "email.template.reservation.exam", examInfo)); - stringValues.put("teacher_name", messaging.get(lang, "email.template.reservation.teacher", teacherName)); - stringValues.put("reservation_date", messaging.get(lang, "email.template.reservation.date", reservationDate)); - stringValues.put( - "exam_duration", - messaging.get(lang, "email.template.reservation.exam.duration", examDuration) - ); - stringValues.put("building_info", messaging.get(lang, "email.template.reservation.building", buildingInfo)); - stringValues.put("room_name", messaging.get(lang, "email.template.reservation.room", roomName)); - stringValues.put("machine_name", messaging.get(lang, "email.template.reservation.machine", machineName)); - stringValues.put("room_instructions", roomInstructions); - stringValues.put("cancellation_info", messaging.get(lang, "email.template.reservation.cancel.info")); - stringValues.put("cancellation_link", hostName); - stringValues.put("cancellation_link_text", messaging.get(lang, "email.template.reservation.cancel.link.text")); - String content = replaceAll(template, stringValues); - - Set attachments = new HashSet<>(); - // Export as iCal format (local reservations only) - if (er == null) { - MailAddress address = machine.getRoom().getMailAddress(); - String addressString = address == null - ? null - : String.format("%s, %s %s", address.getStreet(), address.getZip(), address.getCity()); - ICalendar iCal = createReservationEvent( - lang, - startDate, - endDate, - addressString, - buildingInfo, - roomName, - machineName - ); - File file; - try { - file = File.createTempFile(exam.getName().replace(" ", "-"), ".ics"); - Biweekly.write(iCal).go(file); - } catch (IOException e) { - logger.error("Failed to create a temporary iCal file on disk!"); - throw new RuntimeException(e); - } - EmailAttachment attachment = new EmailAttachment(); - attachment.setPath(file.getAbsolutePath()); - attachment.setDisposition(EmailAttachment.ATTACHMENT); - attachment.setName(messaging.get(lang, "ical.reservation.filename", ".ics")); - attachments.add(attachment); - } - Mail mail = new Mail() - .recipient(recipient.getEmail()) - .sender(systemAccount) - .subject(subject) - .content(content) - .attachment(attachments); - emailSender.send(mail); - } - - private ICalendar createReservationEvent( - Lang lang, - DateTime start, - DateTime end, - String address, - String... placeInfo - ) { - List info = Stream.of(placeInfo).filter(s -> s != null && !s.isEmpty()).toList(); - ICalendar iCal = new ICalendar(); - iCal.setVersion(ICalVersion.V2_0); - VEvent event = new VEvent(); - Summary summary = event.setSummary(messaging.get(lang, "ical.reservation.summary")); - summary.setLanguage(lang.code()); - event.setDateStart(start.toDate()); - event.setDateEnd(end.toDate()); - event.setLocation(address); - event.setDescription(messaging.get(lang, "ical.reservation.room.info", String.join(", ", info))); - iCal.addEvent(event); - return iCal; - } - - @Override - public void composeExamReviewRequest(User toUser, User fromUser, Exam exam, String message) { - String templatePath = getTemplatesRoot() + "reviewRequest.html"; - String template = fileHandler.read(templatePath); - Lang lang = getLang(toUser); - String subject = messaging.get(lang, "email.review.request.subject"); - String teacherName = String.format( - "%s %s <%s>", - fromUser.getFirstName(), - fromUser.getLastName(), - fromUser.getEmail() - ); - String examInfo = String.format("%s (%s)", exam.getName(), exam.getCourse().getCode().split("_")[0]); - String linkToInspection = String.format("%s/staff/exams/%d/4", hostName, exam.getId()); - - Map values = new HashMap<>(); - - List exams = DB.find(Exam.class) - .where() - .eq("parent.id", exam.getId()) - .eq("state", Exam.State.REVIEW) - .findList(); - - int uninspectedCount = exams.size(); - - if (uninspectedCount > 0 && uninspectedCount < 6) { - String list = exams - .stream() - .map(e -> String.format("
  • %s %s
  • ", e.getCreator().getFirstName(), e.getCreator().getLastName())) - .collect(Collectors.joining()); - values.put("student_list", String.format("
      %s
    ", list)); - } else { - template = template.replace("

    {{student_list}}

    ", ""); - } - values.put("new_reviewer", messaging.get(lang, "email.template.inspector.new", teacherName)); - values.put("exam_info", examInfo); - values.put("participation_count", messaging.get(lang, "email.template.participation", uninspectedCount)); - values.put("inspector_message", messaging.get(lang, "email.template.inspector.message")); - values.put("exam_link", linkToInspection); - values.put("exam_link_text", messaging.get(lang, "email.template.link.to.exam")); - values.put("comment_from_assigner", message); - - //Replace template strings - template = replaceAll(template, values); - Mail mail = new Mail() - .recipient(toUser.getEmail()) - .sender(fromUser.getEmail()) - .subject(subject) - .content(template); - emailSender.send(mail); - } - - private String getTeachersAsText(Set owners) { - return owners - .stream() - .map(eo -> String.format("%s %s", eo.getFirstName(), eo.getLastName())) - .collect(Collectors.joining(", ")); - } - - @Override - public void composeReservationChangeNotification( - User student, - ExamMachine previous, - ExamMachine current, - ExamEnrolment enrolment - ) { - String template = fileHandler.read(getTemplatesRoot() + "reservationChanged.html"); - Lang lang = getLang(student); - Exam exam = enrolment.getExam(); - - String examInfo = exam != null - ? String.format("%s (%s)", exam.getName(), exam.getCourse().getCode().split("_")[0]) - : enrolment.getCollaborativeExam().getName(); - - String teacherName = ""; - if (exam != null) { - if (!exam.getExamOwners().isEmpty()) { - teacherName = getTeachers(exam); - } else { - teacherName = String.format("%s %s", exam.getCreator().getFirstName(), exam.getCreator().getLastName()); - } - } - - DateTime startDate = adjustDST(enrolment.getReservation().getStartAt()); - DateTime endDate = adjustDST(enrolment.getReservation().getEndAt()); - String reservationDate = DTF.print(startDate) + " - " + DTF.print(endDate); - - Map values = new HashMap<>(); - String subject = messaging.get( - lang, - "email.template.reservation.change.subject", - exam != null ? enrolment.getExam().getName() : enrolment.getCollaborativeExam().getName() - ); - values.put("message", messaging.get(lang, "email.template.reservation.change.message")); - values.put("previousMachine", messaging.get(lang, "email.template.reservation.change.previous")); - values.put( - "previousMachineName", - messaging.get(lang, "email.template.reservation.machine", previous.getName()) - ); - values.put( - "previousRoom", - messaging.get(lang, "email.template.reservation.room", previous.getRoom().getName()) - ); - values.put( - "previousBuilding", - messaging.get(lang, "email.template.reservation.building", previous.getRoom().getBuildingName()) - ); - values.put("currentMachine", messaging.get(lang, "email.template.reservation.change.current")); - values.put("currentMachineName", messaging.get(lang, "email.template.reservation.machine", current.getName())); - values.put("currentRoom", messaging.get(lang, "email.template.reservation.room", current.getRoom().getName())); - values.put( - "currentBuilding", - messaging.get(lang, "email.template.reservation.building", current.getRoom().getBuildingName()) - ); - values.put("examinationInfo", messaging.get(lang, "email.template.reservation.exam.info")); - values.put("examInfo", messaging.get(lang, "email.template.reservation.exam", examInfo)); - values.put("teachers", messaging.get(lang, "email.template.reservation.teacher", teacherName)); - values.put("reservationTime", messaging.get(lang, "email.template.reservation.date", reservationDate)); - values.put("cancellationInfo", messaging.get(lang, "email.template.reservation.cancel.info")); - values.put("cancellationLink", hostName); - values.put("cancellationLinkText", messaging.get(lang, "email.template.reservation.cancel.link.text")); - - String content = replaceAll(template, values); - Mail mail = new Mail().recipient(student.getEmail()).sender(systemAccount).subject(subject).content(content); - emailSender.send(mail); - } - - private void sendReservationCancellationNotification( - Map values, - String message, - String info, - Lang lang, - String email, - String template, - String subject - ) { - values.put("cancellation_information", message == null ? "" : String.format("%s:
    %s", info, message)); - values.put("regards", messaging.get(lang, "email.template.regards")); - values.put("admin", messaging.get(lang, "email.template.admin")); - - String content = replaceAll(template, values); - Mail mail = new Mail().recipient(email).sender(systemAccount).subject(subject).content(content); - emailSender.send(mail); - } - - private void doComposeReservationSelfCancellationNotification( - String email, - Lang lang, - Reservation reservation, - String message, - ExamEnrolment enrolment - ) { - String templatePath = getTemplatesRoot() + "reservationCanceledByStudent.html"; - String template = fileHandler.read(templatePath); - String subject = messaging.get(lang, "email.reservation.cancellation.subject"); - String room = reservation.getMachine() != null - ? reservation.getMachine().getRoom().getName() - : reservation.getExternalReservation().getRoomName(); - String info = messaging.get(lang, "email.reservation.cancellation.info"); - - Map stringValues = new HashMap<>(); - String time = String.format( - "%s - %s", - DTF.print(adjustDST(reservation.getStartAt())), - DTF.print(adjustDST(reservation.getEndAt())) - ); - final Set owners = enrolment.getExam().getParent() != null - ? enrolment.getExam().getParent().getExamOwners() - : enrolment.getExam().getExamOwners(); - stringValues.put("message", messaging.get(lang, "email.template.reservation.cancel.message.student")); - - final String examName = enrolment.getExam() != null - ? enrolment.getExam().getName() + " (" + enrolment.getExam().getCourse().getCode().split("_")[0] + ")" - : enrolment.getCollaborativeExam().getName(); - stringValues.put("exam", messaging.get(lang, "email.template.reservation.exam", examName)); - stringValues.put( - "teacher", - messaging.get(lang, "email.template.reservation.teacher", getTeachersAsText(owners)) - ); - stringValues.put("time", messaging.get(lang, "email.template.reservation.date", time)); - stringValues.put("place", messaging.get(lang, "email.template.reservation.room", room)); - stringValues.put("new_time", messaging.get(lang, "email.template.reservation.cancel.message.student.new.time")); - stringValues.put("link", hostName); - sendReservationCancellationNotification(stringValues, message, info, lang, email, template, subject); - } - - private void doComposeReservationAdminCancellationNotification( - String email, - Lang lang, - Reservation reservation, - String message, - String examName - ) { - String templatePath = getTemplatesRoot() + "reservationCanceled.html"; - - String template = fileHandler.read(templatePath); - String subject = messaging.get(lang, "email.reservation.cancellation.subject.forced", examName); - - String date = DF.print(adjustDST(reservation.getStartAt())); - String room = reservation.getMachine() != null - ? reservation.getMachine().getRoom().getName() - : reservation.getExternalReservation().getRoomName(); - String info = messaging.get(lang, "email.reservation.cancellation.info"); - - Map stringValues = new HashMap<>(); - String time = TF.print(adjustDST(reservation.getStartAt())); - stringValues.put("message", messaging.get(lang, "email.template.reservation.cancel.message", date, time, room)); - - sendReservationCancellationNotification(stringValues, message, info, lang, email, template, subject); - } - - @Override - public void composeExternalReservationCancellationNotification(Reservation reservation, String message) { - doComposeReservationAdminCancellationNotification( - reservation.getExternalUserRef(), - Lang.forCode("en"), - reservation, - message, - "externally managed exam" - ); - } - - @Override - public void composeReservationCancellationNotification( - User student, - Reservation reservation, - String message, - Boolean isStudentUser, - ExamEnrolment enrolment - ) { - String email = student.getEmail(); - Lang lang = getLang(student); - if (isStudentUser) { - doComposeReservationSelfCancellationNotification(email, lang, reservation, message, enrolment); - } else { - String examName = enrolment.getExam() != null - ? enrolment.getExam().getName() - : enrolment.getCollaborativeExam().getName(); - doComposeReservationAdminCancellationNotification(email, lang, reservation, message, examName); - } - } - - private static String getTeachers(Exam exam) { - Set teachers = new HashSet<>(exam.getExamOwners()); - teachers.addAll(exam.getExamInspections().stream().map(ExamInspection::getUser).collect(Collectors.toSet())); - return teachers - .stream() - .map(t -> String.format("%s %s <%s>", t.getFirstName(), t.getLastName(), t.getEmail())) - .collect(Collectors.joining(", ")); - } - - @Override - public void composePrivateExamParticipantNotification(User student, User fromUser, Exam exam) { - String templatePath = getTemplatesRoot() + "participationNotification.html"; - String template = fileHandler.read(templatePath); - Lang lang = getLang(student); - - boolean isMaturity = exam.getExecutionType().getType().equals(ExamExecutionType.Type.MATURITY.toString()); - boolean isAquarium = exam.getImplementation().toString().equals(Exam.Implementation.AQUARIUM.toString()); - String templatePrefix = String.format("email.template%s.", isMaturity ? ".maturity" : ""); - - String subject = messaging.get( - lang, - templatePrefix + "participant.notification.subject", - String.format("%s (%s)", exam.getName(), exam.getCourse().getCode().split("_")[0]) - ); - String title = messaging.get( - lang, - isAquarium - ? templatePrefix + "participant.notification.title" - : "email.template.participant.notification.title.examination.event" - ); - - String examInfo = messaging.get( - lang, - "email.template.participant.notification.exam", - String.format("%s (%s)", exam.getName(), exam.getCourse().getCode().split("_")[0]) - ); - String teacherName = messaging.get(lang, "email.template.participant.notification.teacher", getTeachers(exam)); - DateTimeFormatter dtf = DateTimeFormat.forPattern("dd.MM.yyyy HH:mm"); - String events = exam - .getExaminationEventConfigurations() - .stream() - .map(c -> c.getExaminationEvent().getStart()) - .sorted() - .map(dtf::print) - .collect(Collectors.joining(", ")); - String examPeriod = isAquarium - ? messaging.get( - lang, - "email.template.participant.notification.exam.period", - String.format( - "%s - %s", - DF.print(new DateTime(exam.getPeriodStart())), - DF.print(new DateTime(exam.getPeriodEnd())) - ) - ) - : messaging.get( - lang, - "email.template.participant.notification.exam.event", - String.format("%s (%s)", events, timeZone) - ); - String examDuration = messaging.get( - lang, - "email.template.participant.notification.exam.duration", - exam.getDuration() - ); - String reservationInfo = isAquarium - ? "" - : String.format("

    %s

    ", messaging.get(lang, "email.template.participant.notification.please.reserve")); - String bookingLink = exam.getImplementation() == Exam.Implementation.AQUARIUM - ? String.format("%s/calendar/%d", hostName, exam.getId()) - : hostName; - Map stringValues = new HashMap<>(); - stringValues.put("title", title); - stringValues.put("exam_info", examInfo); - stringValues.put("teacher_name", teacherName); - stringValues.put("exam_period", examPeriod); - stringValues.put("exam_duration", examDuration); - stringValues.put("reservation_info", reservationInfo); - stringValues.put("booking_link", bookingLink); - String content = replaceAll(template, stringValues); - Mail mail = new Mail() - .recipient(student.getEmail()) - .sender(fromUser.getEmail()) - .subject(subject) - .content(content); - emailSender.send(mail); - } - - @Override - public void composePrivateExamEnded(User toUser, Exam exam) { - Lang lang = getLang(toUser); - User student = exam.getCreator(); - String templatePath, subject, message; - boolean isMaturity = exam.getExecutionType().getType().equals(ExamExecutionType.Type.MATURITY.toString()); - String templatePrefix = String.format("email.template%s.", isMaturity ? ".maturity" : ""); - Map stringValues = new HashMap<>(); - if (exam.getState() == Exam.State.ABORTED) { - templatePath = getTemplatesRoot() + "examAborted.html"; - subject = messaging.get(lang, templatePrefix + "exam.aborted.subject"); - message = messaging.get( - lang, - templatePrefix + "exam.aborted.message", - String.format("%s %s <%s>", student.getFirstName(), student.getLastName(), student.getEmail()), - String.format("%s (%s)", exam.getName(), exam.getCourse().getCode().split("_")[0]) - ); - } else { - templatePath = getTemplatesRoot() + "examEnded.html"; - subject = messaging.get(lang, templatePrefix + "exam.returned.subject"); - message = messaging.get( - lang, - templatePrefix + "exam.returned.message", - String.format("%s %s <%s>", student.getFirstName(), student.getLastName(), student.getEmail()), - String.format("%s (%s)", exam.getName(), exam.getCourse().getCode().split("_")[0]) - ); - String reviewLinkUrl = String.format("%s/staff/assessments/%d", hostName, exam.getId()); - String reviewLinkText = messaging.get(lang, "email.template.exam.returned.link"); - stringValues.put("review_link", reviewLinkUrl); - stringValues.put("review_link_text", reviewLinkText); - } - stringValues.put("message", message); - String template = fileHandler.read(templatePath); - String content = replaceAll(template, stringValues); - Mail mail = new Mail().recipient(toUser.getEmail()).sender(systemAccount).subject(subject).content(content); - emailSender.send(mail); - } - - @Override - public void composeNoShowMessage(User toUser, User student, Exam exam) { - String templatePath = getTemplatesRoot() + "noShow.html"; - String template = fileHandler.read(templatePath); - Lang lang = getLang(toUser); - boolean isMaturity = exam.getExecutionType().getType().equals(ExamExecutionType.Type.MATURITY.toString()); - String templatePrefix = String.format("email.template%s.", isMaturity ? ".maturity" : ""); - String subject = messaging.get(lang, templatePrefix + "noshow.subject"); - String message = messaging.get( - lang, - "email.template.noshow.message", - String.format("%s %s <%s>", student.getFirstName(), student.getLastName(), student.getEmail()), - String.format("%s (%s)", exam.getName(), exam.getCourse().getCode().split("_")[0]) - ); - Map stringValues = new HashMap<>(); - stringValues.put("message", message); - String content = replaceAll(template, stringValues); - Mail mail = new Mail().recipient(toUser.getEmail()).sender(systemAccount).subject(subject).content(content); - emailSender.send(mail); - } - - @Override - public void composeNoShowMessage(User student, String examName, String courseCode) { - String templatePath = getTemplatesRoot() + "noShow.html"; - String template = fileHandler.read(templatePath); - Lang lang = getLang(student); - String sanitizedCode = courseCode.isEmpty() ? courseCode : String.format(" (%s)", courseCode); - String subject = messaging.get(lang, "email.template.noshow.student.subject"); - String message = messaging.get(lang, "email.template.noshow.student.message", examName, sanitizedCode); - Map stringValues = new HashMap<>(); - stringValues.put("message", message); - String content = replaceAll(template, stringValues); - Mail mail = new Mail().recipient(student.getEmail()).sender(systemAccount).subject(subject).content(content); - emailSender.send(mail); - } - - @Override - public void composeLanguageInspectionFinishedMessage(User toUser, User inspector, LanguageInspection inspection) { - String templatePath = getTemplatesRoot() + "languageInspectionReady.html"; - String template = fileHandler.read(templatePath); - Lang lang = getLang(inspector); - - Exam exam = inspection.getExam(); - String subject = messaging.get(lang, "email.template.language.inspection.subject"); - String inspectorName = String.format( - "%s %s <%s>", - inspector.getFirstName(), - inspector.getLastName(), - inspector.getEmail() - ); - String studentName = String.format( - "%s %s <%s>", - exam.getCreator().getFirstName(), - exam.getCreator().getLastName(), - exam.getCreator().getEmail() - ); - String verdict = messaging.get( - lang, - inspection.getApproved() - ? "email.template.language.inspection.approved" - : "email.template.language.inspection.rejected" - ); - String examInfo = String.format("%s, %s", exam.getName(), exam.getCourse().getCode().split("_")[0]); - - String linkToInspection = String.format("%s/staff/assessments/%d", hostName, inspection.getExam().getId()); - - Map stringValues = new HashMap<>(); - stringValues.put("exam_info", messaging.get(lang, "email.template.reservation.exam", examInfo)); - stringValues.put("inspector_name", messaging.get(lang, "email.template.reservation.teacher", inspectorName)); - stringValues.put( - "student_name", - messaging.get(lang, "email.template.language.inspection.student", studentName) - ); - stringValues.put("inspection_done", messaging.get(lang, "email.template.language.inspection.done")); - stringValues.put("statement_title", messaging.get(lang, "email.template.language.inspection.statement.title")); - stringValues.put("inspection_link_text", messaging.get(lang, "email.template.link.to.review")); - stringValues.put("inspection_info", messaging.get(lang, "email.template.language.inspection.result", verdict)); - stringValues.put("inspection_link", linkToInspection); - stringValues.put("inspection_statement", inspection.getStatement().getComment()); - //Replace template strings - template = replaceAll(template, stringValues); - - //Send notification - Mail mail = new Mail() - .recipient(toUser.getEmail()) - .sender(inspector.getEmail()) - .subject(subject) - .content(template); - emailSender.send(mail); - } - - @Override - public void composeCollaborativeExamAnnouncement(Set emails, User sender, Exam exam) { - String templatePath = getTemplatesRoot() + "collaborativeExamNotification.html"; - String template = fileHandler.read(templatePath); - String subject = "New collaborative exam"; - Lang lang = Lang.forCode("en"); - String examInfo = exam.getName(); - String examPeriod = messaging.get( - lang, - "email.template.participant.notification.exam.period", - String.format( - "%s - %s", - DF.print(new DateTime(exam.getPeriodStart())), - DF.print(new DateTime(exam.getPeriodEnd())) - ) - ); - String examDuration = String.format( - "%dh %dmin", - exam.getDuration() / MINUTES_IN_HOUR, - exam.getDuration() % MINUTES_IN_HOUR - ); - - Map stringValues = new HashMap<>(); - stringValues.put("exam_info", messaging.get(lang, "email.template.reservation.exam", examInfo)); - stringValues.put("exam_period", examPeriod); - stringValues.put( - "exam_duration", - messaging.get(lang, "email.template.reservation.exam.duration", examDuration) - ); - - //Replace template strings - template = replaceAll(template, stringValues); - - //Send notification - Mail mail = new Mail() - .recipient(emails) - .sender(sender.getEmail()) - .cc(sender.getEmail()) - .subject(subject) - .content(template); - emailSender.send(mail); - } - - private List getEnrolments(Exam exam) { - return exam - .getExamEnrolments() - .stream() - .filter(ee -> { - Reservation reservation = ee.getReservation(); - ExaminationEventConfiguration eec = ee.getExaminationEventConfiguration(); - if (reservation != null) { - return reservation.getStartAt().isAfter(DateTime.now()); - } else if (eec != null) { - return eec.getExaminationEvent().getStart().isAfter(DateTime.now()); - } - return false; - }) - .sorted() - .toList(); - } - - private String createEnrolmentBlock(User teacher, Lang lang) { - String enrolmentTemplatePath = getTemplatesRoot() + "weeklySummary/enrollmentInfo.html"; - String enrolmentTemplate = fileHandler.read(enrolmentTemplatePath); - List exams = DB.find(Exam.class) - .fetch("course") - .fetch("examEnrolments") - .fetch("examEnrolments.reservation") - .fetch("examEnrolments.examinationEventConfiguration.examinationEvent") - .where() - .disjunction() - .eq("examOwners", teacher) - .eq("examInspections.user", teacher) - .endJunction() - .isNotNull("course") - .eq("state", Exam.State.PUBLISHED) - .gt("periodEnd", new Date()) - .findList(); - - return exams - .stream() - .map(e -> new Tuple2<>(e, getEnrolments(e))) - .filter(t -> !t._1.isPrivate() || !t._2.isEmpty()) - .map(t -> { - Map stringValues = new HashMap<>(); - stringValues.put("exam_link", String.format("%s/staff/reservations/%d", hostName, t._1.getId())); - stringValues.put("exam_name", t._1.getName()); - stringValues.put("course_code", t._1.getCourse().getCode().split("_")[0]); - String subTemplate; - if (t._2.isEmpty()) { - String noEnrolments = messaging.get(lang, "email.enrolment.no.enrolments"); - subTemplate = String.format( - "
  • {{exam_name}} - {{course_code}}: %s
  • ", - noEnrolments - ); - } else { - ExamEnrolment first = t._2.getFirst(); - DateTime date = first.getReservation() != null - ? adjustDST(first.getReservation().getStartAt()) - : new DateTime( - first.getExaminationEventConfiguration().getExaminationEvent().getStart(), - timeZone - ); - stringValues.put( - "enrolments", - messaging.get(lang, "email.template.enrolment.first", t._2.size(), DTF.print(date)) - ); - subTemplate = enrolmentTemplate; - } - return replaceAll(subTemplate, stringValues); - }) - .collect(Collectors.joining()); - } - - // return exams in review state where teacher is either owner or inspector - private static List getReviews(User teacher) { - return DB.find(ExamParticipation.class) - .fetch("exam.course") - .where() - .disjunction() - .eq("exam.parent.examOwners", teacher) - .eq("exam.examInspections.user", teacher) - .endJunction() - .disjunction() - .eq("exam.state", Exam.State.REVIEW) - .eq("exam.state", Exam.State.REVIEW_STARTED) - .endJunction() - .findList(); - } - - private static > SortedSet> sortByValue(Map map) { - SortedSet> set = new TreeSet<>((e1, e2) -> { - int res = e1.getValue().compareTo(e2.getValue()); - return res != 0 ? res : 1; - }); - set.addAll(map.entrySet()); - return set; - } - - private static String replaceAll(String original, Map stringValues) { - String result = original; - for (Entry entry : stringValues.entrySet()) { - if (result.contains(entry.getKey())) { - String value = entry.getValue(); - result = result.replace(TAG_OPEN + entry.getKey() + TAG_CLOSE, value == null ? "" : value); - } - } - return result; - } - - private static String forceNotNull(String src) { - return src == null ? "" : src; - } - - private static Lang getLang(User user) { - Language userLang = user.getLanguage(); - return Lang.forCode(userLang == null ? "en" : userLang.getCode()); - } - - private DateTime adjustDST(DateTime date) { - DateTime dateTime = new DateTime(date, timeZone); - if (!timeZone.isStandardOffset(date.getMillis())) { - dateTime = dateTime.minusHours(1); - } - return dateTime; - } -} diff --git a/app/impl/ExamUpdaterImpl.java b/app/impl/ExamUpdaterImpl.java index 9be18fa89..68201c302 100644 --- a/app/impl/ExamUpdaterImpl.java +++ b/app/impl/ExamUpdaterImpl.java @@ -1,5 +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 @@ -8,6 +7,7 @@ import static play.mvc.Results.badRequest; import static play.mvc.Results.forbidden; +import impl.mail.EmailComposer; import io.ebean.DB; import java.util.ArrayList; import java.util.HashSet; diff --git a/app/impl/NoShowHandlerImpl.scala b/app/impl/NoShowHandlerImpl.scala index 84ac94a5b..91c287f45 100644 --- a/app/impl/NoShowHandlerImpl.scala +++ b/app/impl/NoShowHandlerImpl.scala @@ -4,6 +4,7 @@ package impl +import impl.mail.EmailComposer import io.ebean.Model import miscellaneous.config.ConfigReader import models.enrolment.{ExamEnrolment, Reservation} diff --git a/app/impl/mail/EmailComposer.scala b/app/impl/mail/EmailComposer.scala new file mode 100644 index 000000000..740692752 --- /dev/null +++ b/app/impl/mail/EmailComposer.scala @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium +// +// SPDX-License-Identifier: EUPL-1.2 + +package impl.mail + +import com.google.inject.ImplementedBy +import models.assessment.LanguageInspection +import models.enrolment.ExamEnrolment +import models.enrolment.ExaminationEvent +import models.enrolment.Reservation +import models.exam.Exam +import models.facility.ExamMachine +import models.iop.CollaborativeExam +import models.user.User + +@ImplementedBy(value = classOf[EmailComposerImpl]) +trait EmailComposer { + + /** Message sent to student when review is ready. + */ + def composeInspectionReady(student: User, reviewer: User, exam: Exam): Unit + + /** Message sent to other inspectors when review is ready. + */ + def composeInspectionMessage(inspector: User, sender: User, exam: Exam, msg: String): Unit + def composeInspectionMessage(inspector: User, sender: User, ce: CollaborativeExam, exam: Exam, msg: String): Unit + + /** Weekly summary report + */ + def composeWeeklySummary(teacher: User): Unit + + /** Message sent to student when reservation has been made. + */ + def composeReservationNotification(student: User, reservation: Reservation, exam: Exam, isReminder: Boolean): Unit + + /** Message sent to student when examination event has been selected. + */ + def composeExaminationEventNotification(student: User, enrolment: ExamEnrolment, isReminder: Boolean): Unit + + /** Message sent to student when examination event has been cancelled. + */ + def composeExaminationEventCancellationNotification(user: User, exam: Exam, event: ExaminationEvent): Unit + def composeExaminationEventCancellationNotification(users: Set[User], exam: Exam, event: ExaminationEvent): Unit + + /** Message sent to newly added inspectors. + */ + def composeExamReviewRequest(toUser: User, fromUser: User, exam: Exam, message: String): Unit + + /** Message sent to student when reservation has been cancelled. + */ + def composeReservationCancellationNotification( + student: User, + reservation: Reservation, + message: Option[String], + isStudentUser: Boolean, + enrolment: ExamEnrolment + ): Unit + + /** Message sent to student when externally made reservation has been cancelled by hosting admin. + */ + def composeExternalReservationCancellationNotification(reservation: Reservation, message: Option[String]): Unit + + /** Message sent to student when reservation has been changed. + */ + def composeReservationChangeNotification( + student: User, + previous: ExamMachine, + current: ExamMachine, + enrolment: ExamEnrolment + ): Unit + + /** Message sent to student when he/she has been enrolled to a private exam. + */ + def composePrivateExamParticipantNotification(student: User, fromUser: User, exam: Exam): Unit + + /** Message sent to teacher when student has finished a private exam. + */ + def composePrivateExamEnded(toUser: User, exam: Exam): Unit + + /** Message sent to teacher when student did not show up for a private exam. + */ + def composeNoShowMessage(toUser: User, student: User, exam: Exam): Unit + + /** Message sent to student when he did not show up for exam. + */ + def composeNoShowMessage(student: User, examName: String, courseCode: String): Unit + + /** Message sent to teacher when language inspection is finished. + */ + def composeLanguageInspectionFinishedMessage(toUser: User, inspector: User, inspection: LanguageInspection): Unit + + /** Message sent to teacher when collaborative exam is created in the system. + */ + def composeCollaborativeExamAnnouncement(emails: Set[String], sender: User, exam: Exam): Unit +} diff --git a/app/impl/mail/EmailComposerImpl.scala b/app/impl/mail/EmailComposerImpl.scala new file mode 100644 index 000000000..13f7537dd --- /dev/null +++ b/app/impl/mail/EmailComposerImpl.scala @@ -0,0 +1,781 @@ +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium +// +// SPDX-License-Identifier: EUPL-1.2 + +package impl.mail + +import biweekly.component.VEvent +import biweekly.{Biweekly, ICalVersion, ICalendar} +import io.ebean.DB +import miscellaneous.config.{ByodConfigHandler, ConfigReader} +import miscellaneous.file.FileHandler +import miscellaneous.scala.DbApiHelper +import models.assessment.LanguageInspection +import models.enrolment.{ExamEnrolment, ExamParticipation, ExaminationEvent, Reservation} +import models.exam.{Exam, ExamExecutionType} +import models.facility.ExamMachine +import models.iop.CollaborativeExam +import models.user.User +import org.apache.commons.mail.EmailAttachment +import org.joda.time.DateTime +import org.joda.time.format.DateTimeFormat +import play.api.i18n.{Lang, MessagesApi} +import play.api.{Environment, Logging, Mode} + +import java.io.{File, FileOutputStream, IOException} +import java.util.Date +import javax.inject.Inject +import scala.jdk.CollectionConverters.* +import scala.util.Using +import scala.util.control.Exception.catching + +object EmailComposerImpl: + private val TAG_OPEN = "{{" + private val TAG_CLOSE = "}}" + private val DTF = DateTimeFormat.forPattern("dd.MM.yyyy HH:mm ZZZ") + private val DF = DateTimeFormat.forPattern("dd.MM.yyyy") + private val TF = DateTimeFormat.forPattern("HH:mm") +class EmailComposerImpl @Inject() ( + private val emailSender: EmailSender, + private val fileHandler: FileHandler, + private val env: Environment, + private val messaging: MessagesApi, + private val configReader: ConfigReader, + private val byodConfigHandler: ByodConfigHandler +) extends EmailComposer + with DbApiHelper + with Logging: + private val hostName = configReader.getHostName + private val timeZone = configReader.getDefaultTimeZone + private val systemAccount = configReader.getSystemAccount + private val baseSystemUrl = configReader.getBaseSystemUrl + private val templateRoot = s"${env.rootPath.getAbsolutePath}/conf/template/email/" + + /** This notification is sent to student, when teacher has reviewed the exam + */ + override def composeInspectionReady(student: User, reviewer: User, exam: Exam): Unit = + val templatePath = s"${templateRoot}reviewReady.html" + val template = fileHandler.read(templatePath) + val lang = getLang(student) + val subject = messaging("email.inspection.ready.subject")(lang) + val examInfo = s"${exam.getName}, ${exam.getCourse.getCode.split("_").head}" + val reviewLink = s"$hostName/participations" + val autoEvaluated = Option(reviewer).isEmpty && Option(exam.getAutoEvaluationConfig).nonEmpty + val stringValues = Map( + "review_done" -> messaging("email.template.review.ready", examInfo)(lang), + "review_link" -> reviewLink, + "review_link_text" -> messaging("email.template.link.to.review")(lang), + "main_system_info" -> messaging("email.template.main.system.info")(lang), + "main_system_url" -> baseSystemUrl, + "review_autoevaluated" -> (if autoEvaluated then messaging("email.template.review.autoevaluated")(lang) else "") + ) + val content = replaceAll(template, stringValues) + val senderEmail = Option(reviewer).map(_.getEmail).getOrElse(systemAccount) + emailSender.send(Mail(student.getEmail, senderEmail, subject, content)) + + private def sendInspectionMessage( + link: String, + teacher: String, + exam: String, + msg: String, + recipient: User, + sender: User + ): Unit = + val lang = getLang(recipient) + val stringValues = Map( + "teacher_review_done" -> messaging("email.template.inspection.done", teacher)(lang), + "inspection_comment_title" -> messaging("email.template.inspection.comment")(lang), + "inspection_link_text" -> messaging("email.template.link.to.review")(lang), + "exam_info" -> exam, + "inspection_link" -> link, + "inspection_comment" -> msg + ) + val templatePath = s"${templateRoot}inspectionReady.html" + val template = fileHandler.read(templatePath) + val content = replaceAll(template, stringValues) + val subject = messaging("email.inspection.comment.subject")(lang) + emailSender.send(Mail(recipient.getEmail, sender.getEmail, subject, content)) + + /** This notification is sent to the creator of exam when assigned inspector has finished inspection + */ + override def composeInspectionMessage( + inspector: User, + sender: User, + ce: CollaborativeExam, + exam: Exam, + msg: String + ): Unit = + val teacherName = s"${sender.getFirstName} ${sender.getLastName} <${sender.getEmail}>" + val examInfo = exam.getName + val linkToInspection = s"%$hostName/staff/assessments/${ce.getId}/collaborative/${ce.getRevision}" + sendInspectionMessage(linkToInspection, teacherName, examInfo, msg, inspector, sender) + + /** This notification is sent to the creator of exam when assigned inspector has finished inspection + */ + override def composeInspectionMessage(inspector: User, sender: User, exam: Exam, msg: String): Unit = + val teacherName = s"${sender.getFirstName} ${sender.getLastName} <${sender.getEmail}>" + val examInfo = s"${exam.getName} (${exam.getCourse.getName})" + val linkToInspection = s"$hostName/staff/assessments/${exam.getId}" + sendInspectionMessage(linkToInspection, teacherName, examInfo, msg, inspector, sender) + + override def composeWeeklySummary(teacher: User): Unit = + val lang = getLang(teacher) + val enrolmentBlock = createEnrolmentBlock(teacher, lang) + val reviews = getReviews(teacher) + if enrolmentBlock.nonEmpty || reviews.nonEmpty then + logger.info(s"Sending weekly report to: ${teacher.getEmail}") + val templatePath = s"${templateRoot}weeklySummary/weeklySummary.html" + val inspectionTemplatePath = s"${templateRoot}weeklySummary/inspectionInfoSimple.html" + val template = fileHandler.read(templatePath) + val inspectionTemplate = fileHandler.read(inspectionTemplatePath) + val subject = messaging("email.weekly.report.subject")(lang) + val totalUngradedExams = reviews.length + val assessments = reviews.groupBy(_.getExam).map((k, v) => k -> (v.length, v.minBy(_.getDeadline).getDeadline)) + val assessmentBlock = assessments.toSeq + .filter(_._2._1 > 0) + .sortBy(_._2._2) + .map(as => + val (exam, (amount, deadline)) = (as._1, (as._2._1, as._2._2)) + val summary = + messaging("email.weekly.report.review.summary", amount, EmailComposerImpl.DF.print(new DateTime(deadline)))( + lang + ) + val values = Map( + "exam_link" -> s"$hostName/staff/exams/${exam.getId}/4", + "exam_name" -> exam.getName, + "course_code" -> Option(exam.getCourse).map(_.getCode.split("_").head).getOrElse(""), + "review_summary" -> summary + ) + replaceAll(inspectionTemplate, values) + ) + .mkString + val values = Map( + "enrolments_title" -> messaging("email.template.weekly.report.enrolments")(lang), + "enrolments_info_title" -> messaging("email.template.weekly.report.enrolments.info")(lang), + "enrolment_info" -> (if enrolmentBlock.isEmpty then "N/A" else enrolmentBlock), + "inspections_title" -> messaging("email.template.weekly.report.inspections.info", totalUngradedExams)(lang), + "inspection_info_own" -> (if assessmentBlock.isEmpty then "N/A" else assessmentBlock) + ) + val content = replaceAll(template, values) + emailSender.send(Mail(teacher.getEmail, systemAccount, subject, content)) + + override def composeExaminationEventNotification( + recipient: User, + enrolment: ExamEnrolment, + isReminder: Boolean + ): Unit = + val templatePath = s"${templateRoot}examinationEventConfirmed.html" + val template = fileHandler.read(templatePath) + val exam = enrolment.getExam + val config = enrolment.getExaminationEventConfiguration + val lang = getLang(recipient) + val subjectTemplate = messaging( + if (isReminder) "email.examinationEvent.reminder.subject" + else "email.examinationEvent.subject" + )(lang) + val subject = s"$subjectTemplate: \"${exam.getName}\"" + val courseCode = Option(exam.getCourse).map(c => s"(${c.getCode.split("_")(0)})").getOrElse("") + val examInfo = s"${exam.getName} $courseCode" + val teacherName = getTeachers(exam) + val startDate = EmailComposerImpl.DTF.print(new DateTime(config.getExaminationEvent.getStart, timeZone)) + val examDuration = s"${exam.getDuration / 60}h ${exam.getDuration % 60}min" + val description = config.getExaminationEvent.getDescription + val settingsFile = + if exam.getImplementation == Exam.Implementation.CLIENT_AUTH then + s"

    ${messaging("email.examinationEvent.file.info")(lang)}

    " + else "" + val stringValues = Map( + "title" -> messaging("email.examinationEvent.title")(lang), + "exam_info" -> messaging("email.template.reservation.exam", examInfo)(lang), + "teacher_name" -> messaging("email.template.reservation.teacher", teacherName)(lang), + "event_date" -> messaging("email.examinationEvent.date", startDate)(lang), + "exam_duration" -> messaging("email.template.reservation.exam.duration", examDuration)(lang), + "description" -> description, + "cancellation_info" -> messaging("email.examinationEvent.cancel.info")(lang), + "cancellation_link" -> hostName, + "cancellation_link_text" -> messaging("email.examinationEvent.cancel.link.text")(lang), + "settings_file_info" -> settingsFile + ) + val content = replaceAll(template, stringValues) + if exam.getImplementation eq Exam.Implementation.CLIENT_AUTH then + // Attach a SEB config file + val quitPassword = + byodConfigHandler.getPlaintextPassword(config.getEncryptedQuitPassword, config.getQuitPasswordSalt) + val fileName = exam.getName.replace(" ", "-") + val file = File.createTempFile(fileName, ".seb") + val data = byodConfigHandler.getExamConfig( + config.getHash, + config.getEncryptedSettingsPassword, + config.getSettingsPasswordSalt, + quitPassword + ) + Using.resource(new FileOutputStream(file)) { stream => + stream.write(data) + } + val attachment = new EmailAttachment + attachment.setPath(file.getAbsolutePath) + attachment.setDisposition(EmailAttachment.ATTACHMENT) + attachment.setName(s"$fileName.seb") + if env.mode eq Mode.Dev then logger.info(s"Wrote SEB config file to ${file.getAbsolutePath}") + emailSender.send(Mail(recipient.getEmail, systemAccount, subject, content, attachments = Set(attachment))) + else emailSender.send(Mail(recipient.getEmail, systemAccount, subject, content)) + + private def generateExaminationEventCancellationMail( + exam: Exam, + event: ExaminationEvent, + lang: Lang, + isForced: Boolean + ) = + val templatePath = s"${templateRoot}examinationEventCancelled.html" + val template = fileHandler.read(templatePath) + val time = EmailComposerImpl.DTF.print(adjustDST(event.getStart)) + val teacherName = getTeachers(exam) + val courseCode = Option(exam.getCourse).map(c => s"(${c.getCode.split("_")(0)})").getOrElse("") + val examInfo = s"${exam.getName} $courseCode" + val msg = + if isForced then "email.examinationEvent.cancel.message.admin" + else "email.examinationEvent.cancel.message.student" + val stringValues = Map( + "exam" -> messaging("email.template.reservation.exam", examInfo)(lang), + "teacher" -> messaging("email.template.reservation.teacher", teacherName)(lang), + "time" -> messaging("email.examinationEvent.date", time)(lang), + "link" -> hostName, + "message" -> messaging(msg)(lang), + "new_time" -> messaging("email.examinationEvent.cancel.message.student.new.time")(lang), + "description" -> event.getDescription + ) + replaceAll(template, stringValues) + + override def composeExaminationEventCancellationNotification( + users: Set[User], + exam: Exam, + event: ExaminationEvent + ): Unit = + users.foreach((user: User) => + val lang = getLang(user) + val content = generateExaminationEventCancellationMail(exam, event, lang, true) + val subject = messaging("email.examinationEvent.cancel.subject")(lang) + // email.examinationEvent.cancel.message.admin + emailSender.send(Mail(user.getEmail, systemAccount, subject, content)) + ) + + override def composeExaminationEventCancellationNotification( + user: User, + exam: Exam, + event: ExaminationEvent + ): Unit = + val lang = getLang(user) + val content = generateExaminationEventCancellationMail(exam, event, lang, false) + val subject = messaging("email.examinationEvent.cancel.subject")(lang) + emailSender.send(Mail(user.getEmail, systemAccount, subject, content)) + + override def composeReservationNotification( + recipient: User, + reservation: Reservation, + exam: Exam, + isReminder: Boolean + ): Unit = + val templatePath = s"${templateRoot}reservationConfirmed.html" + val template = fileHandler.read(templatePath) + val lang = getLang(recipient) + val subjectTemplate = + if (isReminder) "email.machine.reservation.reminder.subject" + else "email.machine.reservation.subject" + val subject = s"${messaging(subjectTemplate)(lang)} \"${exam.getName}\"" + val courseCode = Option(exam.getCourse).map(c => s"(${c.getCode.split("_")(0)})").getOrElse("") + val examInfo = s"${exam.getName} $courseCode" + val teacherName = + if !exam.getExamOwners.isEmpty then getTeachers(exam) + else s"${exam.getCreator.getFirstName} ${exam.getCreator.getLastName}" + val startDate = adjustDST(reservation.getStartAt) + val endDate = adjustDST(reservation.getEndAt) + val reservationDate = s"${EmailComposerImpl.DTF.print(startDate)} - ${EmailComposerImpl.DTF.print(endDate)}" + val examDuration = s"${exam.getDuration / 60}h ${exam.getDuration % 60}min" + val machine = reservation.getMachine + val er = reservation.getExternalReservation + val machineName = Option(er).map(_.getMachineName).getOrElse(machine.getName) + val buildingInfo = Option(er).map(_.getBuildingName).getOrElse(machine.getRoom.getBuildingName) + val roomInstructions = + if Option(er).isEmpty then Option(machine.getRoom.getRoomInstructions(lang.asJava)).getOrElse("") + else Option(er.getRoomInstructions(lang.asJava)).getOrElse("") + val roomName = Option(er).map(_.getRoomName).getOrElse(machine.getRoom.getName) + + val stringValues = Map( + "title" -> messaging("email.template.reservation.new")(lang), + "exam_info" -> messaging("email.template.reservation.exam", examInfo)(lang), + "teacher_name" -> messaging("email.template.reservation.teacher", teacherName)(lang), + "reservation_date" -> messaging("email.template.reservation.date", reservationDate)(lang), + "exam_duration" -> messaging("email.template.reservation.exam.duration", examDuration)(lang), + "building_info" -> messaging("email.template.reservation.building", buildingInfo)(lang), + "room_name" -> messaging("email.template.reservation.room", roomName)(lang), + "machine_name" -> messaging("email.template.reservation.machine", machineName)(lang), + "room_instructions" -> roomInstructions, + "cancellation_info" -> messaging("email.template.reservation.cancel.info")(lang), + "cancellation_link" -> hostName, + "cancellation_link_text" -> messaging("email.template.reservation.cancel.link.text")(lang) + ) + val content = replaceAll(template, stringValues) + val mail = Mail(recipient.getEmail, systemAccount, subject, content) + // Export as iCal format (local reservations only) + if Option(er).isEmpty then + val address = machine.getRoom.getMailAddress + val addressString = Option(address).map(a => s"${a.getStreet}, ${a.getZip} ${a.getCity}").getOrElse("") + val iCal = + createReservationEvent(lang, startDate, endDate, addressString, List(buildingInfo, roomName, machineName)) + val file = File.createTempFile(exam.getName.replace(" ", "-"), ".ics") + catching(classOf[IOException]).either { + Biweekly.write(iCal).go(file) + } match + case Left(e) => + logger.error("Failed to create a temporary iCal file on disk!") + throw new RuntimeException(e) + case _ => // OK + val attachment = new EmailAttachment + attachment.setPath(file.getAbsolutePath) + attachment.setDisposition(EmailAttachment.ATTACHMENT) + attachment.setName(messaging("ical.reservation.filename", ".ics")(lang)) + emailSender.send(mail.copy(attachments = Set(attachment))) + else emailSender.send(mail) + + private def createReservationEvent( + lang: Lang, + start: DateTime, + end: DateTime, + address: String, + placeInfo: List[String] + ) = + val iCal = new ICalendar + iCal.setVersion(ICalVersion.V2_0) + val event = new VEvent + + val summary = event.setSummary(messaging("ical.reservation.summary")(lang)) + summary.setLanguage(lang.code) + event.setDateStart(start.toDate) + event.setDateEnd(end.toDate) + event.setLocation(address) + val roomInfo = placeInfo.mkString(", ") + event.setDescription(messaging("ical.reservation.room.info", roomInfo)(lang)) + iCal.addEvent(event) + iCal + + override def composeExamReviewRequest(toUser: User, fromUser: User, exam: Exam, message: String): Unit = + val templatePath = s"${templateRoot}reviewRequest.html" + val template = fileHandler.read(templatePath) + val lang = getLang(toUser) + val subject = messaging("email.review.request.subject")(lang) + val teacherName = s"${fromUser.getFirstName} ${fromUser.getLastName} <${fromUser.getEmail}>" + val examInfo = s"${exam.getName} (${exam.getCourse.getCode.split("_")(0)}" + val linkToInspection = s"$hostName/staff/exams/${exam.getId}/4" + val exams = DB.find(classOf[Exam]).where.eq("parent.id", exam.getId).eq("state", Exam.State.REVIEW).list + val values = Map( + "new_reviewer" -> messaging("email.template.inspector.new", teacherName)(lang), + "exam_info" -> examInfo, + "participation_count" -> messaging("email.template.participation", exams.length)(lang), + "inspector_message" -> messaging("email.template.inspector.message")(lang), + "exam_link" -> linkToInspection, + "exam_link_text" -> messaging("email.template.link.to.exam")(lang), + "comment_from_assigner" -> message + ) + val content = if exams.nonEmpty && exams.length < 6 then + val list = exams.map(e => s"
  • ${e.getCreator.getFirstName} ${e.getCreator.getLastName}
  • ").mkString + replaceAll(template, values + ("student_list" -> s"
      $list
    )")) + else replaceAll(template.replace("

    {{student_list}}

    ", ""), values) + emailSender.send(Mail(toUser.getEmail, fromUser.getEmail, subject, content)) + + private def getTeachersAsText(owners: Set[User]) = owners + .map(o => s"${o.getFirstName} ${o.getLastName}") + .mkString(", ") + + override def composeReservationChangeNotification( + student: User, + previous: ExamMachine, + current: ExamMachine, + enrolment: ExamEnrolment + ): Unit = + val template = fileHandler.read(s"${templateRoot}reservationChanged.html") + val lang = getLang(student) + val exam = enrolment.getExam + val examInfo = Option(exam) + .map(e => s"${exam.getName} (${exam.getCourse.getCode.split("_")(0)})") + .getOrElse(enrolment.getCollaborativeExam.getName) + val teacherName = + if !exam.getExamOwners.isEmpty then getTeachers(exam) + else s"${exam.getCreator.getFirstName} ${exam.getCreator.getLastName}" + val startDate = adjustDST(enrolment.getReservation.getStartAt) + val endDate = adjustDST(enrolment.getReservation.getEndAt) + val reservationDate = s"${EmailComposerImpl.DTF.print(startDate)} - ${EmailComposerImpl.DTF.print(endDate)}" + val examName = Option(exam).map(_.getName).getOrElse(enrolment.getCollaborativeExam.getName) + val subject = messaging("email.template.reservation.change.subject", examName)(lang) + + val values = Map( + "message" -> messaging("email.template.reservation.change.message")(lang), + "previousMachine" -> messaging("email.template.reservation.change.previous")(lang), + "previousMachineName" -> messaging("email.template.reservation.machine", previous.getName)(lang), + "previousRoom" -> messaging("email.template.reservation.room", previous.getRoom.getName)(lang), + "previousBuilding" -> messaging("email.template.reservation.building", previous.getRoom.getBuildingName)(lang), + "currentMachine" -> messaging("email.template.reservation.change.current")(lang), + "currentMachineName" -> messaging("email.template.reservation.machine", current.getName)(lang), + "currentRoom" -> messaging("email.template.reservation.room", current.getRoom.getName)(lang), + "currentBuilding" -> messaging("email.template.reservation.building", current.getRoom.getBuildingName)(lang), + "examinationInfo" -> messaging("email.template.reservation.exam.info")(lang), + "examInfo" -> messaging("email.template.reservation.exam", examInfo)(lang), + "teachers" -> messaging("email.template.reservation.teacher", teacherName)(lang), + "reservationTime" -> messaging("email.template.reservation.date", reservationDate)(lang), + "cancellationInfo" -> messaging("email.template.reservation.cancel.info")(lang), + "cancellationLink" -> hostName, + "cancellationLinkText" -> messaging("email.template.reservation.cancel.link.text")(lang) + ) + val content = replaceAll(template, values) + emailSender.send(Mail(student.getEmail, systemAccount, subject, content)) + + private def sendReservationCancellationNotification( + values: Map[String, String], + message: Option[String], + info: String, + lang: Lang, + email: String, + template: String, + subject: String + ): Unit = + val extraValues = Map( + "cancellation_information" -> message.map(msg => s"$info:
    $msg").getOrElse(""), + "regards" -> messaging("email.template.regards")(lang), + "admin" -> messaging("email.template.admin")(lang) + ) + val content = replaceAll(template, values ++ extraValues) + emailSender.send(Mail(email, systemAccount, subject, content)) + + private def doComposeReservationSelfCancellationNotification( + email: String, + lang: Lang, + reservation: Reservation, + message: Option[String], + enrolment: ExamEnrolment + ): Unit = + val templatePath = s"${templateRoot}reservationCanceledByStudent.html" + val template = fileHandler.read(templatePath) + val subject = messaging("email.reservation.cancellation.subject")(lang) + val room = + Option(reservation.getMachine).map(_.getRoom.getName).getOrElse(reservation.getExternalReservation.getRoomName) + val info = messaging("email.reservation.cancellation.info")(lang) + val time = s"${EmailComposerImpl.DTF + .print(adjustDST(reservation.getStartAt))} - ${EmailComposerImpl.DTF.print(adjustDST(reservation.getEndAt))}" + val owners = Option(enrolment.getExam.getParent).map(_.getExamOwners).getOrElse(enrolment.getExam.getExamOwners) + val examName = Option(enrolment.getExam) + .map(e => s"${e.getName} (${e.getCourse.getCode.split("_")(0)})") + .getOrElse(enrolment.getCollaborativeExam.getName) + val stringValues = Map( + "message" -> messaging("email.template.reservation.cancel.message.student")(lang), + "exam" -> messaging("email.template.reservation.exam", examName)(lang), + "teacher" -> messaging("email.template.reservation.teacher", getTeachersAsText(owners.asScala.toSet))(lang), + "time" -> messaging("email.template.reservation.date", time)(lang), + "place" -> messaging("email.template.reservation.room", room)(lang), + "new_time" -> messaging("email.template.reservation.cancel.message.student.new.time")(lang), + "link" -> hostName + ) + sendReservationCancellationNotification(stringValues, message, info, lang, email, template, subject) + + private def doComposeReservationAdminCancellationNotification( + email: String, + lang: Lang, + reservation: Reservation, + message: Option[String], + examName: String + ): Unit = + val templatePath = s"${templateRoot}reservationCanceled.html" + val template = fileHandler.read(templatePath) + val subject = messaging("email.reservation.cancellation.subject.forced", examName)(lang) + val date = EmailComposerImpl.DF.print(adjustDST(reservation.getStartAt)) + val room = + Option(reservation.getMachine).map(_.getRoom.getName).getOrElse(reservation.getExternalReservation.getRoomName) + val info = messaging("email.reservation.cancellation.info")(lang) + val time = EmailComposerImpl.TF.print(adjustDST(reservation.getStartAt)) + val stringValues = Map( + "message" -> messaging("email.template.reservation.cancel.message", date, time, room)(lang) + ) + sendReservationCancellationNotification(stringValues, message, info, lang, email, template, subject) + + override def composeExternalReservationCancellationNotification( + reservation: Reservation, + message: Option[String] + ): Unit = + doComposeReservationAdminCancellationNotification( + reservation.getExternalUserRef, + Lang.get("en").get, + reservation, + message, + "externally managed exam" + ) + override def composeReservationCancellationNotification( + student: User, + reservation: Reservation, + message: Option[String], + isStudentUser: Boolean, + enrolment: ExamEnrolment + ): Unit = + val email = student.getEmail + val lang = getLang(student) + if isStudentUser then doComposeReservationSelfCancellationNotification(email, lang, reservation, message, enrolment) + else + val examName = + if (enrolment.getExam != null) enrolment.getExam.getName + else enrolment.getCollaborativeExam.getName + doComposeReservationAdminCancellationNotification(email, lang, reservation, message, examName) + + private def getTeachers(exam: Exam) = + val teachers = exam.getExamOwners.asScala + val inspectors = exam.getExamInspections.asScala.map(_.getUser) + (teachers ++ inspectors).map(u => s"${u.getFirstName} ${u.getLastName} <${u.getEmail}>").mkString(", ") + + override def composePrivateExamParticipantNotification(student: User, fromUser: User, exam: Exam): Unit = + val templatePath = s"${templateRoot}participationNotification.html" + val template = fileHandler.read(templatePath) + val lang = getLang(student) + val isMaturity = exam.getExecutionType.getType == ExamExecutionType.Type.MATURITY.toString + val isAquarium = exam.getImplementation.toString == Exam.Implementation.AQUARIUM.toString + val templatePrefix = s"email.template${if isMaturity then ".maturity" else ""}." + val subject = messaging( + s"${templatePrefix}participant.notification.subject", + s"${exam.getName} (${exam.getCourse.getCode.split("_")(0)})" + )(lang) + val title = messaging( + if isAquarium then s"${templatePrefix}participant.notification.title" + else "email.template.participant.notification.title.examination.event" + )(lang) + val examInfo = messaging( + "email.template.participant.notification.exam", + s"${exam.getName} (${exam.getCourse.getCode.split("_")(0)})" + )(lang) + val teacherName = messaging("email.template.participant.notification.teacher", getTeachers(exam))(lang) + val dtf = DateTimeFormat.forPattern("dd.MM.yyyy HH:mm") + val events = exam.getExaminationEventConfigurations.asScala.toList + .map(_.getExaminationEvent.getStart) + .sorted + .map(dtf.print) + .mkString(", ") + val examPeriod = + if isAquarium then + messaging( + "email.template.participant.notification.exam.period", + s"${EmailComposerImpl.DF.print(new DateTime(exam.getPeriodStart))} - ${EmailComposerImpl.DF + .print(new DateTime(exam.getPeriodEnd))}" + )(lang) + else messaging("email.template.participant.notification.exam.event", s"$events ($timeZone)")(lang) + + val examDuration = messaging("email.template.participant.notification.exam.duration", exam.getDuration)(lang) + val reservationInfo = + if isAquarium then "" + else s"

    ${messaging("email.template.participant.notification.please.reserve")(lang)}

    " + val bookingLink = + if exam.getImplementation == Exam.Implementation.AQUARIUM then s"$hostName/calendar/${exam.getId}}" + else hostName + val stringValues = Map( + "title" -> title, + "exam_info" -> examInfo, + "teacher_name" -> teacherName, + "exam_period" -> examPeriod, + "exam_duration" -> examDuration, + "reservation_info" -> reservationInfo, + "booking_link" -> bookingLink + ) + + val content = replaceAll(template, stringValues) + emailSender.send(Mail(student.getEmail, fromUser.getEmail, subject, content)) + + override def composePrivateExamEnded(toUser: User, exam: Exam): Unit = + val lang = getLang(toUser) + val student = exam.getCreator + val isMaturity = exam.getExecutionType.getType == ExamExecutionType.Type.MATURITY.toString + val templatePrefix = s"email.template${if isMaturity then ".maturity" else ""}." + val templatePath = + if exam.getState == Exam.State.ABORTED then s"${templateRoot}examAborted.html" + else s"${templateRoot}examEnded.html" + val subject = + if exam.getState == Exam.State.ABORTED then messaging(templatePrefix + "exam.aborted.subject")(lang) + else messaging(templatePrefix + "exam.returned.subject")(lang) + val path = if exam.getState == Exam.State.ABORTED then "aborted" else "returned" + val msg = + messaging( + s"${templatePrefix}exam.$path.message", + s"${student.getFirstName} ${student.getLastName} <${student.getEmail}>", + s"${exam.getName} (${exam.getCourse.getCode.split("_")(0)})" + )(lang) + val reviewLinkUrl = s"$hostName/staff/assessments/${exam.getId}" + val reviewLinkText = messaging("email.template.exam.returned.link")(lang) + val stringValues = Map( + "review_link" -> reviewLinkUrl, + "review_link_text" -> reviewLinkText, + "message" -> msg + ) + val template = fileHandler.read(templatePath) + val content = replaceAll(template, stringValues) + emailSender.send(Mail(toUser.getEmail, systemAccount, subject, content)) + + override def composeNoShowMessage(toUser: User, student: User, exam: Exam): Unit = + val templatePath = s"${templateRoot}noShow.html" + val template = fileHandler.read(templatePath) + val lang = getLang(toUser) + val isMaturity = exam.getExecutionType.getType == ExamExecutionType.Type.MATURITY.toString + val templatePrefix = s"email.template${if isMaturity then ".maturity" else ""}." + val subject = messaging(s"${templatePrefix}noshow.subject")(lang) + val message = messaging( + "email.template.noshow.message", + s"${student.getFirstName} ${student.getLastName} <${student.getEmail}>", + s"${exam.getName} (${exam.getCourse.getCode.split("_")(0)})" + )(lang) + val stringValues = Map( + "message" -> message + ) + val content = replaceAll(template, stringValues) + emailSender.send(Mail(toUser.getEmail, systemAccount, subject, content)) + + override def composeNoShowMessage(student: User, examName: String, courseCode: String): Unit = + val templatePath = s"${templateRoot}noShow.html" + val template = fileHandler.read(templatePath) + val lang = getLang(student) + val sanitizedCode = if courseCode.isEmpty then courseCode else s" ($courseCode)" + val subject = messaging("email.template.noshow.student.subject")(lang) + val message = messaging("email.template.noshow.student.message", examName, sanitizedCode)(lang) + val stringValues = Map( + "message" -> message + ) + val content = replaceAll(template, stringValues) + emailSender.send(Mail(student.getEmail, systemAccount, subject, content)) + + override def composeLanguageInspectionFinishedMessage( + toUser: User, + inspector: User, + inspection: LanguageInspection + ): Unit = + val templatePath = s"${templateRoot}languageInspectionReady.html" + val template = fileHandler.read(templatePath) + val lang = getLang(inspector) + val exam = inspection.getExam + val subject = messaging("email.template.language.inspection.subject")(lang) + val studentName = + s"${exam.getCreator.getFirstName} ${exam.getCreator.getLastName} <${exam.getCreator.getEmail}>" + val inspectorName = s"${inspector.getFirstName} ${inspector.getLastName} <${inspector.getEmail}>" + val verdict = messaging( + if (inspection.getApproved) "email.template.language.inspection.approved" + else "email.template.language.inspection.rejected" + )(lang) + val examInfo = s"${exam.getName}, ${exam.getCourse.getCode.split("_")(0)}" + val linkToInspection = s"$hostName/staff/assessments/${inspection.getExam.getId}" + val stringValues = Map( + "exam_info" -> messaging("email.template.reservation.exam", examInfo)(lang), + "inspector_name" -> messaging("email.template.reservation.teacher", inspectorName)(lang), + "student_name" -> messaging("email.template.language.inspection.student", studentName)(lang), + "inspection_done" -> messaging("email.template.language.inspection.done")(lang), + "statement_title" -> messaging("email.template.language.inspection.statement.title")(lang), + "inspection_link_text" -> messaging("email.template.link.to.review")(lang), + "inspection_info" -> messaging("email.template.language.inspection.result", verdict)(lang), + "inspection_link" -> linkToInspection, + "inspection_statement" -> inspection.getStatement.getComment + ) + val content = replaceAll(template, stringValues) + emailSender.send(Mail(toUser.getEmail, inspector.getEmail, subject, content)) + + override def composeCollaborativeExamAnnouncement(emails: Set[String], sender: User, exam: Exam): Unit = + val templatePath = s"${templateRoot}collaborativeExamNotification.html" + val template = fileHandler.read(templatePath) + val subject = "New collaborative exam" + val lang = Lang.get("en").get + val examInfo = exam.getName + val examPeriod = messaging( + "email.template.participant.notification.exam.period", + s"%${EmailComposerImpl.DF.print(new DateTime(exam.getPeriodStart))} - ${EmailComposerImpl.DF + .print(new DateTime(exam.getPeriodEnd))}" + )(lang) + val examDuration = s"${exam.getDuration / 60}h ${exam.getDuration % 60}min" + val stringValues = Map( + "exam_info" -> messaging("email.template.reservation.exam", examInfo)(lang), + "exam_period" -> examPeriod, + "exam_duration" -> messaging("email.template.reservation.exam.duration", examDuration)(lang) + ) + val content = replaceAll(template, stringValues) + emailSender.send(Broadcast(emails, sender.getEmail, subject, content, cc = Set(sender.getEmail))) + + private def getEnrolments(exam: Exam) = exam.getExamEnrolments.asScala + .filter(ee => + val reservation = ee.getReservation + val eec = ee.getExaminationEventConfiguration + if Option(reservation).nonEmpty then reservation.getStartAt.isAfterNow + else if Option(eec).nonEmpty then eec.getExaminationEvent.getStart.isAfterNow + else false + ) + .sorted + .toList + + private def createEnrolmentBlock(teacher: User, lang: Lang) = + val enrolmentTemplatePath = s"${templateRoot}weeklySummary/enrollmentInfo.html" + val enrolmentTemplate = fileHandler.read(enrolmentTemplatePath) + DB + .find(classOf[Exam]) + .fetch("course") + .fetch("examEnrolments") + .fetch("examEnrolments.reservation") + .fetch("examEnrolments.examinationEventConfiguration.examinationEvent") + .where + .disjunction + .eq("examOwners", teacher) + .eq("examInspections.user", teacher) + .endJunction + .isNotNull("course") + .eq("state", Exam.State.PUBLISHED) + .gt("periodEnd", new Date) + .list + .map(e => (e, getEnrolments(e))) + .filter((e, ees) => e.isPrivate || ees.isEmpty) + .map((exam, enrolments) => + val commonValues = Map( + "exam_link" -> s"$hostName/staff/reservations/${exam.getId}", + "exam_name" -> exam.getName, + "course_code" -> exam.getCourse.getCode.split("_").head + ) + val subTemplate = if enrolments.isEmpty then + val noEnrolments = messaging("email.enrolment.no.enrolments")(lang) + s"
  • {{exam_name}} - {{course_code}}: $noEnrolments
  • " + else enrolmentTemplate + val values = + if enrolments.isEmpty then commonValues + else + val first = enrolments.head + val date = + if Option(first.getReservation).nonEmpty then adjustDST(first.getReservation.getStartAt) + else new DateTime(first.getExaminationEventConfiguration.getExaminationEvent.getStart, timeZone) + commonValues + ("enrolments" -> + messaging("email.template.enrolment.first", enrolments.length, EmailComposerImpl.DTF.print(date))(lang)) + + replaceAll(subTemplate, values) + ) + .mkString + +// return exams in review state where teacher is either owner or inspector + private def getReviews(teacher: User) = DB + .find(classOf[ExamParticipation]) + .fetch("exam.course") + .where + .disjunction + .eq("exam.parent.examOwners", teacher) + .eq("exam.examInspections.user", teacher) + .endJunction + .disjunction + .eq("exam.state", Exam.State.REVIEW) + .eq("exam.state", Exam.State.REVIEW_STARTED) + .endJunction + .list + + private def replaceAll(original: String, values: Map[String, String]) = + values.foldLeft(original)((acc, kv) => + acc.replace(s"${EmailComposerImpl.TAG_OPEN}${kv._1}${EmailComposerImpl.TAG_CLOSE}", kv._2) + ) + + private def getLang(user: User) = + val code = Option(user.getLanguage).map(_.getCode).getOrElse("en") + Lang.get(code).get + + private def adjustDST(date: DateTime) = { + val dateTime = new DateTime(date, timeZone) + if !timeZone.isStandardOffset(date.getMillis) then dateTime.minusHours(1) + else dateTime + } diff --git a/app/impl/mail/EmailSender.java b/app/impl/mail/EmailSender.java deleted file mode 100644 index ba4e60133..000000000 --- a/app/impl/mail/EmailSender.java +++ /dev/null @@ -1,82 +0,0 @@ -// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// -// SPDX-License-Identifier: EUPL-1.2 - -package impl.mail; - -import com.google.inject.ImplementedBy; -import java.util.HashSet; -import java.util.Set; -import org.apache.commons.mail.EmailAttachment; - -@FunctionalInterface -@ImplementedBy(EmailSenderImpl.class) -public interface EmailSender { - void send(Mail mail); - - class Mail { - - Set recipients = new HashSet<>(); - String sender; - String subject; - String content; - Set cc = new HashSet<>(); - Set bcc = new HashSet<>(); - Set attachments = new HashSet<>(); - - public Mail recipient(String recipient) { - this.recipients.add(recipient); - return this; - } - - public Mail recipient(Set recipients) { - this.recipients.addAll(recipients); - return this; - } - - public Mail sender(String sender) { - this.sender = sender; - return this; - } - - public Mail subject(String subject) { - this.subject = subject; - return this; - } - - public Mail content(String content) { - this.content = content; - return this; - } - - public Mail cc(String cc) { - this.cc.add(cc); - return this; - } - - public Mail cc(Set cc) { - this.cc.addAll(cc); - return this; - } - - public Mail bcc(String bcc) { - this.bcc.add(bcc); - return this; - } - - public Mail bcc(Set bcc) { - this.bcc.addAll(bcc); - return this; - } - - public Mail attachment(EmailAttachment attachment) { - this.attachments.add(attachment); - return this; - } - - public Mail attachment(Set attachments) { - this.attachments.addAll(attachments); - return this; - } - } -} diff --git a/app/impl/mail/EmailSender.scala b/app/impl/mail/EmailSender.scala new file mode 100644 index 000000000..3dfd1caee --- /dev/null +++ b/app/impl/mail/EmailSender.scala @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium +// +// SPDX-License-Identifier: EUPL-1.2 + +package impl.mail + +import com.google.inject.ImplementedBy +import org.apache.commons.mail.EmailAttachment + +@ImplementedBy(classOf[EmailSenderImpl]) +trait EmailSender: + def send(mail: Dispatch): Unit + +sealed trait Dispatch: + val sender: String + val subject: String + val content: String + val cc: Set[String] + val bcc: Set[String] + val attachments: Set[EmailAttachment] + +case class Broadcast( + recipients: Set[String], + sender: String, + subject: String, + content: String, + cc: Set[String] = Set.empty, + bcc: Set[String] = Set.empty, + attachments: Set[EmailAttachment] = Set.empty +) extends Dispatch + +case class Mail( + recipient: String, + sender: String, + subject: String, + content: String, + cc: Set[String] = Set.empty, + bcc: Set[String] = Set.empty, + attachments: Set[EmailAttachment] = Set.empty +) extends Dispatch diff --git a/app/impl/mail/EmailSenderImpl.java b/app/impl/mail/EmailSenderImpl.java deleted file mode 100644 index 38979390f..000000000 --- a/app/impl/mail/EmailSenderImpl.java +++ /dev/null @@ -1,87 +0,0 @@ -// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium -// -// SPDX-License-Identifier: EUPL-1.2 - -package impl.mail; - -import com.typesafe.config.Config; -import java.util.Set; -import javax.inject.Inject; -import org.apache.commons.mail.DefaultAuthenticator; -import org.apache.commons.mail.EmailAttachment; -import org.apache.commons.mail.EmailException; -import org.apache.commons.mail.HtmlEmail; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class EmailSenderImpl implements EmailSender { - - private final Logger logger = LoggerFactory.getLogger(EmailSenderImpl.class); - private final Config config; - - @Inject - public EmailSenderImpl(Config config) { - this.config = config; - } - - private void mockSending(HtmlEmail email, String content, Set attachments) { - logger.info("mock implementation, send email"); - logger.info("subject: {}", email.getSubject()); - logger.info("from: {}", email.getFromAddress()); - logger.info("body: {}", content); - email.getToAddresses().forEach(a -> logger.info("to: {}", a)); - email.getReplyToAddresses().forEach(a -> logger.info("replyTo: {}", a)); - email.getCcAddresses().forEach(a -> logger.info("cc: {}", a)); - attachments.forEach(a -> logger.info("attachment: {}", a.getName())); - } - - private void doSend( - Set recipients, - String sender, - Set cc, - Set bcc, - String subject, - String content, - Set attachments - ) throws EmailException { - HtmlEmail email = new HtmlEmail(); - email.setCharset("utf-8"); - for (EmailAttachment attachment : attachments) { - email.attach(attachment); - } - email.setHostName(config.getString("play.mailer.host")); - email.setSmtpPort(config.getInt("play.mailer.port")); - email.setAuthenticator( - new DefaultAuthenticator(config.getString("play.mailer.user"), config.getString("play.mailer.password")) - ); - email.setSSLOnConnect(config.getString("play.mailer.ssl").equals("YES")); - email.setSubject(subject); - for (String r : recipients) { - email.addTo(r); - } - email.setFrom(String.format("Exam <%s>", config.getString("exam.email.system.account"))); - email.addReplyTo(sender); - for (String addr : cc) { - email.addCc(addr); - } - for (String addr : bcc) { - email.addBcc(addr); - } - email.setHtmlMsg(content); - boolean useMock = config.hasPath("play.mailer.mock") && config.getBoolean("play.mailer.mock"); - if (useMock) { - mockSending(email, content, attachments); - } else { - email.send(); - } - } - - @Override - public void send(Mail mail) { - try { - doSend(mail.recipients, mail.sender, mail.cc, mail.bcc, mail.subject, mail.content, mail.attachments); - } catch (EmailException e) { - logger.error("Creating mail failed. Stacktrace follows", e); - } - } -} diff --git a/app/impl/mail/EmailSenderImpl.scala b/app/impl/mail/EmailSenderImpl.scala new file mode 100644 index 000000000..f461e25db --- /dev/null +++ b/app/impl/mail/EmailSenderImpl.scala @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium +// +// SPDX-License-Identifier: EUPL-1.2 + +package impl.mail + +import com.typesafe.config.Config +import org.apache.commons.mail.{DefaultAuthenticator, EmailAttachment, EmailException, HtmlEmail} +import play.api.Logging + +import javax.inject.Inject +import scala.jdk.CollectionConverters.* +import scala.util.control.Exception.catching + +class EmailSenderImpl @Inject() (private val config: Config) extends EmailSender with Logging: + + private def mockSending(email: HtmlEmail, content: String, attachments: Set[EmailAttachment]): Unit = + logger.info("mock implementation, send email") + logger.info(s"subject: ${email.getSubject}") + logger.info(s"from: ${email.getFromAddress}") + logger.info(s"body: $content") + email.getToAddresses.asScala.foreach(a => logger.info("to: $a")) + email.getReplyToAddresses.asScala.foreach(a => logger.info(s"replyTo: $a")) + email.getCcAddresses.asScala.foreach(a => logger.info(s"cc: $a")) + attachments.foreach(a => logger.info(s"attachment: ${a.getName}")) + + private def doSend( + recipients: Set[String], + sender: String, + cc: Set[String], + bcc: Set[String], + subject: String, + content: String, + attachments: Set[EmailAttachment] + ): Unit = + val email = new HtmlEmail + email.setCharset("utf-8") + attachments.foreach(email.attach) + email.setHostName(config.getString("play.mailer.host")) + email.setSmtpPort(config.getInt("play.mailer.port")) + email.setAuthenticator( + new DefaultAuthenticator(config.getString("play.mailer.user"), config.getString("play.mailer.password")) + ) + email.setSSLOnConnect(config.getString("play.mailer.ssl") == "YES") + email.setSubject(subject) + recipients.foreach(email.addTo) + email.setFrom(String.format("Exam <%s>", config.getString("exam.email.system.account"))) + email.addReplyTo(sender) + cc.foreach(email.addCc) + bcc.foreach(email.addBcc) + email.setHtmlMsg(content) + if config.hasPath("play.mailer.mock") && config.getBoolean("play.mailer.mock") then + mockSending(email, content, attachments) + else email.send + + override def send(dispatch: Dispatch): Unit = + val recipients = dispatch match + case bc: Broadcast => bc.recipients + case m: Mail => Set(m.recipient) + + catching(classOf[EmailException]).either( + doSend( + recipients, + dispatch.sender, + dispatch.cc, + dispatch.bcc, + dispatch.subject, + dispatch.content, + dispatch.attachments + ) + ) match + case Left(e) => logger.error("Creating mail failed. Stacktrace follows", e) + case _ => // OK diff --git a/app/system/SystemInitializer.scala b/app/system/SystemInitializer.scala index 76ffa67cc..c394ec7d8 100644 --- a/app/system/SystemInitializer.scala +++ b/app/system/SystemInitializer.scala @@ -4,7 +4,7 @@ package system -import impl.EmailComposer +import impl.mail.EmailComposer import io.ebean.DB import org.apache.pekko.actor.ActorRef import org.apache.pekko.actor.ActorSystem diff --git a/app/system/actors/AutoEvaluationNotifierActor.scala b/app/system/actors/AutoEvaluationNotifierActor.scala index 92bf55997..b135c13c4 100644 --- a/app/system/actors/AutoEvaluationNotifierActor.scala +++ b/app/system/actors/AutoEvaluationNotifierActor.scala @@ -4,7 +4,7 @@ package system.actors -import impl.EmailComposer +import impl.mail.EmailComposer import io.ebean.DB import models.assessment.AutoEvaluationConfig.ReleaseType import org.apache.pekko.actor.AbstractActor diff --git a/app/system/actors/ExamAutoSaverActor.scala b/app/system/actors/ExamAutoSaverActor.scala index eed0dd133..9ec4c2158 100644 --- a/app/system/actors/ExamAutoSaverActor.scala +++ b/app/system/actors/ExamAutoSaverActor.scala @@ -5,7 +5,7 @@ package system.actors import controllers.admin.SettingsController -import impl.EmailComposer +import impl.mail.EmailComposer import io.ebean.DB import miscellaneous.datetime.DateTimeHandler import miscellaneous.scala.DbApiHelper diff --git a/app/system/actors/ReservationReminderActor.scala b/app/system/actors/ReservationReminderActor.scala index 19db7a7b4..bdfdfd5c1 100644 --- a/app/system/actors/ReservationReminderActor.scala +++ b/app/system/actors/ReservationReminderActor.scala @@ -4,7 +4,7 @@ package system.actors -import impl.EmailComposer +import impl.mail.EmailComposer import io.ebean.DB import org.apache.pekko.actor.AbstractActor import org.joda.time.DateTime diff --git a/ui/src/assets/components/vendor/ckeditor/plugins/clozetest/dialogs/cloze.js b/ui/src/assets/components/vendor/ckeditor/plugins/clozetest/dialogs/cloze.js index 80d8fce94..ab43aa83e 100644 --- a/ui/src/assets/components/vendor/ckeditor/plugins/clozetest/dialogs/cloze.js +++ b/ui/src/assets/components/vendor/ckeditor/plugins/clozetest/dialogs/cloze.js @@ -1,6 +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/assets/components/vendor/ckeditor/plugins/clozetest/lang/en.js b/ui/src/assets/components/vendor/ckeditor/plugins/clozetest/lang/en.js index 41cfce952..6c63f7849 100644 --- a/ui/src/assets/components/vendor/ckeditor/plugins/clozetest/lang/en.js +++ b/ui/src/assets/components/vendor/ckeditor/plugins/clozetest/lang/en.js @@ -1,4 +1,3 @@ -// Copyright (c) 2018 The members of the EXAM Consortium (https://confluence.csc.fi/display/EXAM/Konsortio-organisaatio) // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/ui/src/assets/components/vendor/ckeditor/plugins/clozetest/lang/fi.js b/ui/src/assets/components/vendor/ckeditor/plugins/clozetest/lang/fi.js index 14d07fefa..6d6a80542 100644 --- a/ui/src/assets/components/vendor/ckeditor/plugins/clozetest/lang/fi.js +++ b/ui/src/assets/components/vendor/ckeditor/plugins/clozetest/lang/fi.js @@ -1,4 +1,3 @@ -// Copyright (c) 2018 The members of the EXAM Consortium (https://confluence.csc.fi/display/EXAM/Konsortio-organisaatio) // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2 diff --git a/ui/src/assets/components/vendor/ckeditor/plugins/clozetest/lang/sv.js b/ui/src/assets/components/vendor/ckeditor/plugins/clozetest/lang/sv.js index c6e45f37f..1df1a6734 100644 --- a/ui/src/assets/components/vendor/ckeditor/plugins/clozetest/lang/sv.js +++ b/ui/src/assets/components/vendor/ckeditor/plugins/clozetest/lang/sv.js @@ -1,4 +1,3 @@ -// Copyright (c) 2018 The members of the EXAM Consortium (https://confluence.csc.fi/display/EXAM/Konsortio-organisaatio) // SPDX-FileCopyrightText: 2024 The members of the EXAM Consortium // // SPDX-License-Identifier: EUPL-1.2