diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ExamDAO.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ExamDAO.java index 7c6b77c9..429a8ddc 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ExamDAO.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ExamDAO.java @@ -94,17 +94,17 @@ public interface ExamDAO extends ActivatableEntityDAO, BulkActionSup * @return Result refer to all exams for LMS update or to an error when happened */ Result> allForLMSUpdate(); - /** This is used to get all Exams to check if they have to set into running state in the meanwhile. - * Gets all exams in the upcoming status for run-check + /** This is used to get all Exams that potentially needs a state change. + * Checks if the stored running time frame of the exam is not in sync with the current state and return + * all exams for this is the case. + * Adding also leadTime before and followupTime after the specified running time frame of the exam for + * this check. * + * @param leadTime Time period in milliseconds that is added to now-time-point to check the start time of the exam + * @param followupTime Time period in milliseconds that is subtracted from now-time-point check the end time of the + * exam * @return Result refer to a collection of exams or to an error if happened */ - Result> allForRunCheck(); - - /** This is used to get all Exams to check if they have to set into finished state in the meanwhile. - * Gets all exams in the running status for end-check - * - * @return Result refer to a collection of exams or to an error if happened */ - Result> allForEndCheck(); + Result> allThatNeedsStatusUpdate(long leadTime, long followupTime); /** Get a collection of all currently running exam identifiers * diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamDAOImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamDAOImpl.java index 665e7b91..1049592e 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamDAOImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamDAOImpl.java @@ -317,16 +317,9 @@ public class ExamDAOImpl implements ExamDAO { } @Override - public Result> allForRunCheck() { + public Result> allThatNeedsStatusUpdate(final long leadTime, final long followupTime) { return this.examRecordDAO - .allForRunCheck() - .flatMap(this::toDomainModel); - } - - @Override - public Result> allForEndCheck() { - return this.examRecordDAO - .allForEndCheck() + .allThatNeedsStatusUpdate(leadTime, followupTime) .flatMap(this::toDomainModel); } @@ -799,4 +792,5 @@ public class ExamDAOImpl implements ExamDAO { return exam; }); } + } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamRecordDAO.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamRecordDAO.java index 5b2e8f10..a7aed3cb 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamRecordDAO.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamRecordDAO.java @@ -19,7 +19,10 @@ import java.util.stream.Collectors; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; import org.mybatis.dynamic.sql.SqlBuilder; +import org.mybatis.dynamic.sql.SqlCriterion; import org.mybatis.dynamic.sql.select.MyBatis3SelectModelAdapter; import org.mybatis.dynamic.sql.select.QueryExpressionDSL; import org.slf4j.Logger; @@ -435,9 +438,51 @@ public class ExamRecordDAO { } @Transactional(readOnly = true) - public Result> allForRunCheck() { + public Result> allThatNeedsStatusUpdate(final long leadTime, final long followupTime) { return Result.tryCatch(() -> { - return this.examRecordMapper.selectByExample() + + final DateTime now = DateTime.now(DateTimeZone.UTC); + final List result = new ArrayList<>(); + + // check those on running state that are not within the time-frame anymore + final List running = this.examRecordMapper.selectByExample() + .where( + ExamRecordDynamicSqlSupport.active, + isEqualTo(BooleanUtils.toInteger(true))) + .and( + ExamRecordDynamicSqlSupport.status, + isEqualTo(ExamStatus.RUNNING.name())) + .and( + ExamRecordDynamicSqlSupport.updating, + isEqualTo(BooleanUtils.toInteger(false))) + .and( // not within time frame + ExamRecordDynamicSqlSupport.quizStartTime, + SqlBuilder.isGreaterThanOrEqualToWhenPresent(now.plus(leadTime)), + or( + ExamRecordDynamicSqlSupport.quizEndTime, + SqlBuilder.isLessThanWhenPresent(now.minus(followupTime)))) + .build() + .execute(); + + // check those in not running state (and not archived) and are within the time-frame or on wrong side of the time-frame + // if finished but up-coming + final SqlCriterion finished = or( + ExamRecordDynamicSqlSupport.status, + isEqualTo(ExamStatus.FINISHED.name()), + and( + ExamRecordDynamicSqlSupport.quizStartTime, + SqlBuilder.isGreaterThanOrEqualToWhenPresent(now.plus(leadTime)))); + + // if up-coming but finished + final SqlCriterion upcoming = or( + ExamRecordDynamicSqlSupport.status, + isEqualTo(ExamStatus.UP_COMING.name()), + and( + ExamRecordDynamicSqlSupport.quizEndTime, + SqlBuilder.isLessThanWhenPresent(now.minus(followupTime))), + finished); + + final List notRunning = this.examRecordMapper.selectByExample() .where( ExamRecordDynamicSqlSupport.active, isEqualTo(BooleanUtils.toInteger(true))) @@ -450,26 +495,19 @@ public class ExamRecordDAO { .and( ExamRecordDynamicSqlSupport.updating, isEqualTo(BooleanUtils.toInteger(false))) + .and( // within time frame + ExamRecordDynamicSqlSupport.quizStartTime, + SqlBuilder.isLessThanWhenPresent(now.plus(leadTime)), + and( + ExamRecordDynamicSqlSupport.quizEndTime, + SqlBuilder.isGreaterThanOrEqualToWhenPresent(now.minus(followupTime))), + upcoming) .build() .execute(); - }); - } - @Transactional(readOnly = true) - public Result> allForEndCheck() { - return Result.tryCatch(() -> { - return this.examRecordMapper.selectByExample() - .where( - ExamRecordDynamicSqlSupport.active, - isEqualTo(BooleanUtils.toInteger(true))) - .and( - ExamRecordDynamicSqlSupport.status, - isEqualTo(ExamStatus.RUNNING.name())) - .and( - ExamRecordDynamicSqlSupport.updating, - isEqualTo(BooleanUtils.toInteger(false))) - .build() - .execute(); + result.addAll(running); + result.addAll(notRunning); + return result; }); } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxCourseRestriction.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxCourseRestriction.java index fceb011e..b6c16975 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxCourseRestriction.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxCourseRestriction.java @@ -79,8 +79,6 @@ public class OpenEdxCourseRestriction implements SEBRestrictionAPI { // not accessible within OAuth2 authentication (just with user - authentication), // we can only check if the endpoint is available for now. This is checked // if there is no 404 response. - // TODO: Ask eduNEXT to implement also OAuth2 API access for this endpoint to be able - // to check the version of the installed plugin. final LmsSetup lmsSetup = this.openEdxRestTemplateFactory.apiTemplateDataSupplier.getLmsSetup(); final String url = lmsSetup.lmsApiUrl + OPEN_EDX_DEFAULT_COURSE_RESTRICTION_API_INFO; diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamResetEvent.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamResetEvent.java new file mode 100644 index 00000000..440c1f72 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamResetEvent.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package ch.ethz.seb.sebserver.webservice.servicelayer.session; + +import org.springframework.context.ApplicationEvent; + +import ch.ethz.seb.sebserver.gbl.model.exam.Exam; + +public class ExamResetEvent extends ApplicationEvent { + + private static final long serialVersionUID = -2854284031889020212L; + + public final Exam exam; + + public ExamResetEvent(final Exam exam) { + super(exam); + this.exam = exam; + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionControlTask.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionControlTask.java index e028ef35..8b1375fc 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionControlTask.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionControlTask.java @@ -12,7 +12,6 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Set; -import java.util.stream.Collectors; import org.joda.time.DateTime; import org.joda.time.DateTimeZone; @@ -107,8 +106,9 @@ public class ExamSessionControlTask implements DisposableBean { } controlExamLMSUpdate(); - controlExamStart(updateId); - controlExamEnd(updateId); + controlExamState(updateId); +// controlExamStart(updateId); +// controlExamEnd(updateId); this.examDAO.releaseAgedLocks(); } @@ -191,7 +191,7 @@ public class ExamSessionControlTask implements DisposableBean { } } - private void controlExamStart(final String updateId) { + private void controlExamState(final String updateId) { if (log.isTraceEnabled()) { log.trace("Check starting exams: {}", updateId); } @@ -199,47 +199,73 @@ public class ExamSessionControlTask implements DisposableBean { try { final DateTime now = DateTime.now(DateTimeZone.UTC); - final Map updated = this.examDAO.allForRunCheck() + this.examDAO + .allThatNeedsStatusUpdate(this.examTimePrefix, this.examTimeSuffix) .getOrThrow() .stream() - .filter(exam -> exam.startTime != null && exam.startTime.minus(this.examTimePrefix).isBefore(now)) - .filter(exam -> exam.endTime == null || exam.endTime.plus(this.examTimeSuffix).isAfter(now)) - .flatMap(exam -> Result.skipOnError(this.examUpdateHandler.setRunning(exam, updateId))) - .collect(Collectors.toMap(Exam::getId, Exam::getName)); - - if (!updated.isEmpty()) { - log.info("Updated exams to running state: {}", updated); - } + .forEach(exam -> this.examUpdateHandler.updateState( + exam, + now, + this.examTimePrefix, + this.examTimeSuffix, + updateId)); } catch (final Exception e) { - log.error("Unexpected error while trying to update exams: ", e); + log.error("Unexpected error while trying to run exam state update task: ", e); } } - private void controlExamEnd(final String updateId) { - if (log.isTraceEnabled()) { - log.trace("Check ending exams: {}", updateId); - } - - try { - - final DateTime now = DateTime.now(DateTimeZone.UTC); - - final Map updated = this.examDAO.allForEndCheck() - .getOrThrow() - .stream() - .filter(exam -> exam.endTime != null && exam.endTime.plus(this.examTimeSuffix).isBefore(now)) - .flatMap(exam -> Result.skipOnError(this.examUpdateHandler.setFinished(exam, updateId))) - .collect(Collectors.toMap(Exam::getId, Exam::getName)); - - if (!updated.isEmpty()) { - log.info("Updated exams to finished state: {}", updated); - } - - } catch (final Exception e) { - log.error("Unexpected error while trying to update exams: ", e); - } - } +// @Deprecated +// private void controlExamStart(final String updateId) { +// if (log.isTraceEnabled()) { +// log.trace("Check starting exams: {}", updateId); +// } +// +// try { +// +// final DateTime now = DateTime.now(DateTimeZone.UTC); +// final Map updated = this.examDAO.allForRunCheck() +// .getOrThrow() +// .stream() +// .filter(exam -> exam.startTime != null && exam.startTime.minus(this.examTimePrefix).isBefore(now)) +// .filter(exam -> exam.endTime == null || exam.endTime.plus(this.examTimeSuffix).isAfter(now)) +// .flatMap(exam -> Result.skipOnError(this.examUpdateHandler.setRunning(exam, updateId))) +// .collect(Collectors.toMap(Exam::getId, Exam::getName)); +// +// if (!updated.isEmpty()) { +// log.info("Updated exams to running state: {}", updated); +// } +// +// } catch (final Exception e) { +// log.error("Unexpected error while trying to update exams: ", e); +// } +// } +// +// @Deprecated +// private void controlExamEnd(final String updateId) { +// if (log.isTraceEnabled()) { +// log.trace("Check ending exams: {}", updateId); +// } +// +// try { +// +// final DateTime now = DateTime.now(DateTimeZone.UTC); +// +// final Map updated = this.examDAO.allForEndCheck() +// .getOrThrow() +// .stream() +// .filter(exam -> exam.endTime != null && exam.endTime.plus(this.examTimeSuffix).isBefore(now)) +// .flatMap(exam -> Result.skipOnError(this.examUpdateHandler.setFinished(exam, updateId))) +// .collect(Collectors.toMap(Exam::getId, Exam::getName)); +// +// if (!updated.isEmpty()) { +// log.info("Updated exams to finished state: {}", updated); +// } +// +// } catch (final Exception e) { +// log.error("Unexpected error while trying to update exams: ", e); +// } +// } private void updateMaster() { this.webserviceInfo.updateMaster(); diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionServiceImpl.java index 9144daeb..84be861e 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionServiceImpl.java @@ -50,6 +50,7 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.dao.IndicatorDAO; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPIService; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.SEBRestrictionService; import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamFinishedEvent; +import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamResetEvent; import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamSessionService; @Lazy @@ -436,6 +437,24 @@ public class ExamSessionServiceImpl implements ExamSessionService { .getAllActiveConnectionTokens(examId); } + @EventListener + public void notifyExamRest(final ExamResetEvent event) { + log.info("ExamResetEvent received, process exam session cleanup..."); + + try { + if (!isExamRunning(event.exam.id)) { + this.flushCache(event.exam); + if (this.distributedSetup) { + this.clientConnectionDAO + .deleteClientIndicatorValues(event.exam) + .getOrThrow(); + } + } + } catch (final Exception e) { + log.error("Failed to cleanup on reset exam: {}", event.exam, e); + } + } + @EventListener public void notifyExamFinished(final ExamFinishedEvent event) { diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamUpdateHandler.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamUpdateHandler.java index 83f1b9ba..093d7037 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamUpdateHandler.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamUpdateHandler.java @@ -40,6 +40,7 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPITemplate; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.SEBRestrictionService; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.moodle.legacy.MoodleCourseAccess; import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamFinishedEvent; +import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamResetEvent; import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamStartedEvent; @Lazy @@ -89,6 +90,10 @@ class ExamUpdateHandler { this.lmsAPIService .getLmsAPITemplate(lmsSetupId) + .map(template -> { + template.clearCourseCache(); + return template; + }) .flatMap(template -> template.getQuizzes(new HashSet<>(exams.keySet()))) .onError(error -> log.warn( "Failed to get quizzes from LMS Setup: {} cause: {}", @@ -151,6 +156,150 @@ class ExamUpdateHandler { }); } + void updateState( + final Exam exam, + final DateTime now, + final long leadTime, + final long followupTime, + final String updateId) { + + try { + // Include leadTime and followupTime + final DateTime startTimeThreshold = now.plus(leadTime); + final DateTime endTimeThreshold = now.minus(leadTime); + + if (log.isDebugEnabled()) { + log.debug("Check exam update for startTimeThreshold: {}, endTimeThreshold {}, exam: {}", + startTimeThreshold, + endTimeThreshold, + exam); + } + + if (exam.status == ExamStatus.ARCHIVED) { + log.warn("Exam in unexpected state for status update. Skip update. Exam: {}", exam); + return; + } + + if (exam.status != ExamStatus.RUNNING && withinTimeframe( + exam.startTime, + startTimeThreshold, + exam.endTime, + endTimeThreshold)) { + + if (withinTimeframe(exam.startTime, startTimeThreshold, exam.endTime, endTimeThreshold)) { + setRunning(exam, updateId) + .onError(error -> log.error("Failed to update exam to running state: {}", + exam, + error)); + return; + } + } + + if (exam.status != ExamStatus.FINISHED && + exam.endTime != null && + endTimeThreshold.isAfter(exam.endTime)) { + setFinished(exam, updateId) + .onError(error -> log.error("Failed to update exam to finished state: {}", + exam, + error)); + return; + } + + if (exam.status != ExamStatus.UP_COMING && + exam.startTime != null && + startTimeThreshold.isBefore(exam.startTime)) { + setUpcoming(exam, updateId) + .onError(error -> log.error("Failed to update exam to up-coming state: {}", + exam, + error)); + } + +// switch (exam.status) { +// case UP_COMING: { +// // move to RUNNING when now is within the running time frame +// if (withinTimeframe(exam.startTime, startTimeThreshold, exam.endTime, endTimeThreshold)) { +// setRunning(exam, updateId) +// .onError(error -> log.error("Failed to update exam to running state: {}", exam, error)); +// break; +// } +// // move to FINISHED when now is behind the end date +// if (exam.endTime != null && endTimeThreshold.isAfter(exam.endTime)) { +// setFinished(exam, updateId) +// .onError( +// error -> log.error("Failed to update exam to finished state: {}", exam, error)); +// break; +// } +// } +// case RUNNING: { +// // move to FINISHED when now is behind the end date +// if (exam.endTime != null && endTimeThreshold.isAfter(exam.endTime)) { +// setFinished(exam, updateId) +// .onError( +// error -> log.error("Failed to update exam to finished state: {}", exam, error)); +// break; +// } +// // move to UP_COMMING when now is before the start date +// break; +// } +// case FINISHED: { +// // move to RUNNING when now is within the running time frame +// // move to UP_COMMING when now is before the start date +// break; +// } +// default: { +// log.warn("Exam for status update in unexpected state. Skip update. Exam: {}", exam); +// } +// } + } catch (final Exception e) { + log.error("Unexpected error while trying to update exam state for exam: {}", exam, e); + } + } + + private boolean withinTimeframe( + final DateTime startTime, + final DateTime startTimeThreshold, + final DateTime endTime, + final DateTime endTimeThreshold) { + + if (startTime == null && endTime == null) { + return true; + } + + if (startTime == null && endTime.isAfter(endTimeThreshold)) { + return true; + } + + if (endTime == null && startTime.isBefore(startTimeThreshold)) { + return true; + } + + return (startTime.isBefore(startTimeThreshold) && endTime.isAfter(endTimeThreshold)); + } + + Result setUpcoming(final Exam exam, final String updateId) { + if (log.isDebugEnabled()) { + log.debug("Update exam as up-coming: {}", exam); + } + + return this.examDAO + .placeLock(exam.id, updateId) + .flatMap(e -> this.examDAO.updateState(exam.id, ExamStatus.UP_COMING, updateId)) + .map(e -> { + this.examDAO + .releaseLock(e, updateId) + .onError(error -> this.examDAO + .forceUnlock(exam.id) + .onError(unlockError -> log.error( + "Failed to force unlock update look for exam: {}", + exam.id))); + return e; + }) + .map(e -> { + this.applicationEventPublisher.publishEvent(new ExamResetEvent(exam)); + return exam; + }); + } + Result setRunning(final Exam exam, final String updateId) { if (log.isDebugEnabled()) { log.debug("Update exam as running: {}", exam);