SEBSERV-339 fixed all exam state changes and optimized code

This commit is contained in:
anhefti 2022-07-14 10:21:56 +02:00
parent 147489b3b0
commit 88ff9511f2
8 changed files with 327 additions and 77 deletions

View file

@ -94,17 +94,17 @@ public interface ExamDAO extends ActivatableEntityDAO<Exam, Exam>, BulkActionSup
* @return Result refer to all exams for LMS update or to an error when happened */
Result<Collection<Exam>> 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<Collection<Exam>> 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<Collection<Exam>> allForEndCheck();
Result<Collection<Exam>> allThatNeedsStatusUpdate(long leadTime, long followupTime);
/** Get a collection of all currently running exam identifiers
*

View file

@ -317,16 +317,9 @@ public class ExamDAOImpl implements ExamDAO {
}
@Override
public Result<Collection<Exam>> allForRunCheck() {
public Result<Collection<Exam>> allThatNeedsStatusUpdate(final long leadTime, final long followupTime) {
return this.examRecordDAO
.allForRunCheck()
.flatMap(this::toDomainModel);
}
@Override
public Result<Collection<Exam>> allForEndCheck() {
return this.examRecordDAO
.allForEndCheck()
.allThatNeedsStatusUpdate(leadTime, followupTime)
.flatMap(this::toDomainModel);
}
@ -799,4 +792,5 @@ public class ExamDAOImpl implements ExamDAO {
return exam;
});
}
}

View file

@ -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<Collection<ExamRecord>> allForRunCheck() {
public Result<Collection<ExamRecord>> allThatNeedsStatusUpdate(final long leadTime, final long followupTime) {
return Result.tryCatch(() -> {
return this.examRecordMapper.selectByExample()
final DateTime now = DateTime.now(DateTimeZone.UTC);
final List<ExamRecord> result = new ArrayList<>();
// check those on running state that are not within the time-frame anymore
final List<ExamRecord> 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<String> finished = or(
ExamRecordDynamicSqlSupport.status,
isEqualTo(ExamStatus.FINISHED.name()),
and(
ExamRecordDynamicSqlSupport.quizStartTime,
SqlBuilder.isGreaterThanOrEqualToWhenPresent(now.plus(leadTime))));
// if up-coming but finished
final SqlCriterion<String> upcoming = or(
ExamRecordDynamicSqlSupport.status,
isEqualTo(ExamStatus.UP_COMING.name()),
and(
ExamRecordDynamicSqlSupport.quizEndTime,
SqlBuilder.isLessThanWhenPresent(now.minus(followupTime))),
finished);
final List<ExamRecord> 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<Collection<ExamRecord>> 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;
});
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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<Long, String> 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<Long, String> 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<Long, String> 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<Long, String> 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();

View file

@ -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) {

View file

@ -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<Exam> 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<Exam> setRunning(final Exam exam, final String updateId) {
if (log.isDebugEnabled()) {
log.debug("Update exam as running: {}", exam);