diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/Exam.java b/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/Exam.java index d4dc9abc..4beda37a 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/Exam.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/Exam.java @@ -17,6 +17,7 @@ import javax.validation.constraints.NotNull; import org.apache.commons.lang3.StringUtils; import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -47,7 +48,8 @@ public final class Exam implements GrantEntity { null, null, ExamStatus.FINISHED, - Boolean.FALSE); + Boolean.FALSE, + null); public static final String FILTER_ATTR_TYPE = "type"; public static final String FILTER_ATTR_STATUS = "status"; @@ -115,6 +117,9 @@ public final class Exam implements GrantEntity { @JsonProperty(EXAM.ATTR_ACTIVE) public final Boolean active; + @JsonProperty(EXAM.ATTR_LASTUPDATE) + public final String lastUpdate; + @JsonCreator public Exam( @JsonProperty(EXAM.ATTR_ID) final Long id, @@ -131,7 +136,8 @@ public final class Exam implements GrantEntity { @JsonProperty(EXAM.ATTR_OWNER) final String owner, @JsonProperty(EXAM.ATTR_SUPPORTER) final Collection supporter, @JsonProperty(EXAM.ATTR_STATUS) final ExamStatus status, - @JsonProperty(EXAM.ATTR_ACTIVE) final Boolean active) { + @JsonProperty(EXAM.ATTR_ACTIVE) final Boolean active, + @JsonProperty(EXAM.ATTR_LASTUPDATE) final String lastUpdate) { this.id = id; this.institutionId = institutionId; @@ -145,8 +151,9 @@ public final class Exam implements GrantEntity { this.type = type; this.quitPassword = quitPassword; this.owner = owner; - this.status = (status != null) ? status : ExamStatus.UP_COMING; + this.status = (status != null) ? status : getStatusFromDate(startTime, endTime); this.active = (active != null) ? active : Boolean.FALSE; + this.lastUpdate = lastUpdate; this.supporter = (supporter != null) ? Collections.unmodifiableCollection(supporter) @@ -167,9 +174,13 @@ public final class Exam implements GrantEntity { this.type = mapper.getEnum(EXAM.ATTR_TYPE, ExamType.class, ExamType.UNDEFINED); this.quitPassword = mapper.getString(EXAM.ATTR_QUIT_PASSWORD); this.owner = mapper.getString(EXAM.ATTR_OWNER); - this.status = mapper.getEnum(EXAM.ATTR_STATUS, ExamStatus.class); + this.status = mapper.getEnum( + EXAM.ATTR_STATUS, + ExamStatus.class, + getStatusFromDate(this.startTime, this.endTime)); this.active = mapper.getBoolean(EXAM.ATTR_ACTIVE); this.supporter = mapper.getStringSet(EXAM.ATTR_SUPPORTER); + this.lastUpdate = null; } public Exam(final QuizData quizzData) { @@ -189,9 +200,10 @@ public final class Exam implements GrantEntity { this.type = null; this.quitPassword = null; this.owner = null; - this.status = (status != null) ? status : ExamStatus.UP_COMING; + this.status = (status != null) ? status : getStatusFromDate(this.startTime, this.endTime); this.active = null; this.supporter = null; + this.lastUpdate = null; } @Override @@ -326,4 +338,17 @@ public final class Exam implements GrantEntity { return builder.toString(); } + public static ExamStatus getStatusFromDate(final DateTime startTime, final DateTime endTime) { + final DateTime now = DateTime.now(DateTimeZone.UTC); + if (startTime != null && now.isBefore(startTime)) { + return ExamStatus.UP_COMING; + } else if (startTime != null && now.isAfter(startTime) && (endTime == null || now.isBefore(endTime))) { + return ExamStatus.RUNNING; + } else if (endTime != null && now.isAfter(endTime)) { + return ExamStatus.FINISHED; + } else { + return ExamStatus.UP_COMING; + } + } + } diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/SebExamConfigSettingsForm.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/SebExamConfigSettingsForm.java index 2342e013..8c051e4e 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/SebExamConfigSettingsForm.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/SebExamConfigSettingsForm.java @@ -23,6 +23,8 @@ import org.springframework.stereotype.Component; import ch.ethz.seb.sebserver.gbl.Constants; import ch.ethz.seb.sebserver.gbl.api.API; +import ch.ethz.seb.sebserver.gbl.api.APIMessage; +import ch.ethz.seb.sebserver.gbl.api.APIMessageError; import ch.ethz.seb.sebserver.gbl.api.EntityType; import ch.ethz.seb.sebserver.gbl.model.EntityKey; import ch.ethz.seb.sebserver.gbl.model.sebconfig.Configuration; @@ -37,6 +39,7 @@ import ch.ethz.seb.sebserver.gui.service.examconfig.impl.AttributeMapping; import ch.ethz.seb.sebserver.gui.service.examconfig.impl.ViewContext; import ch.ethz.seb.sebserver.gui.service.i18n.LocTextKey; import ch.ethz.seb.sebserver.gui.service.page.PageContext; +import ch.ethz.seb.sebserver.gui.service.page.PageMessageException; import ch.ethz.seb.sebserver.gui.service.page.PageService; import ch.ethz.seb.sebserver.gui.service.page.TemplateComposer; import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.RestService; @@ -61,10 +64,12 @@ public class SebExamConfigSettingsForm implements TemplateComposer { "sebserver.examconfig.action.saveToHistory.success"; private static final String KEY_UNDO_SUCCESS = "sebserver.examconfig.action.undo.success"; - private static final LocTextKey TITLE_TEXT_KEY = new LocTextKey("sebserver.examconfig.props.from.title"); + private static final LocTextKey MESSAGE_SAVE_INTEGRITY_VIOLATION = + new LocTextKey("sebserver.examconfig.action.saveToHistory.integrity-violation"); + private final PageService pageService; private final RestService restService; private final CurrentUser currentUser; @@ -150,8 +155,7 @@ public class SebExamConfigSettingsForm implements TemplateComposer { this.restService.getBuilder(SaveExamConfigHistory.class) .withURIVariable(API.PARAM_MODEL_ID, configuration.getModelId()) .call() - .onError(pageContext::notifyError) - .getOrThrow(); + .onError(t -> notifyErrorOnSave(t, pageContext)); return action; }) .withSuccess(KEY_SAVE_TO_HISTORY_SUCCESS) @@ -164,7 +168,6 @@ public class SebExamConfigSettingsForm implements TemplateComposer { this.restService.getBuilder(SebExamConfigUndo.class) .withURIVariable(API.PARAM_MODEL_ID, configuration.getModelId()) .call() - .onError(pageContext::notifyError) .getOrThrow(); return action; }) @@ -175,9 +178,7 @@ public class SebExamConfigSettingsForm implements TemplateComposer { .newAction(ActionDefinition.SEB_EXAM_CONFIG_VIEW_PROP) .withEntityKey(entityKey) .ignoreMoveAwayFromEdit() - .publish() - - ; + .publish(); } catch (final RuntimeException e) { log.error("Unexpected error while trying to fetch exam configuration data and create views", e); @@ -186,7 +187,24 @@ public class SebExamConfigSettingsForm implements TemplateComposer { log.error("Unexpected error while trying to fetch exam configuration data and create views", e); pageContext.notifyError(e); } + } + private void notifyErrorOnSave(final Throwable error, final PageContext context) { + if (error instanceof APIMessageError) { + try { + final List errorMessages = ((APIMessageError) error).getErrorMessages(); + final APIMessage apiMessage = errorMessages.get(0); + if (APIMessage.ErrorMessage.INTEGRITY_VALIDATION.isOf(apiMessage)) { + throw new PageMessageException(MESSAGE_SAVE_INTEGRITY_VIOLATION); + } else { + throw error; + } + } catch (final PageMessageException e) { + throw e; + } catch (final Throwable e) { + throw new RuntimeException(error); + } + } } } diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/page/impl/PageContextImpl.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/page/impl/PageContextImpl.java index 91e19e57..91c55dc0 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/page/impl/PageContextImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/page/impl/PageContextImpl.java @@ -303,7 +303,6 @@ public class PageContextImpl implements PageContext { @Override public T notifyError(final Throwable error) { - log.error("Unexpected error: ", error); notifyError(error.getMessage(), error); return null; } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ExamConfigurationMapDAO.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ExamConfigurationMapDAO.java index f7f2168d..663af65f 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ExamConfigurationMapDAO.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ExamConfigurationMapDAO.java @@ -38,7 +38,7 @@ public interface ExamConfigurationMapDAO extends * @param examId The Exam identifier * @return ConfigurationNode identifier of the default Exam Configuration of * the Exam with specified identifier */ - Result getDefaultConfigurationForExam(Long examId); + Result getDefaultConfigurationNode(Long examId); /** Get the ConfigurationNode identifier of the Exam Configuration of * the Exam for a specified user identifier. @@ -47,7 +47,7 @@ public interface ExamConfigurationMapDAO extends * @param userId the user identifier * @return ConfigurationNode identifier of the Exam Configuration of * the Exam for a specified user identifier */ - Result getUserConfigurationIdForExam(final Long examId, final String userId); + Result getUserConfigurationNodeId(final Long examId, final String userId); /** Get all id of Exams that has a relation to the given configuration id. * 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 39b35aee..c79d9e46 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 @@ -44,11 +44,13 @@ public interface ExamDAO extends ActivatableEntityDAO, BulkActionSup Result> allForEndCheck(); - Result startUpdate(Long examId, String update); + Result placeLock(Long examId, String update); - Result endUpdate(Long examId, String update); + Result releaseLock(Long examId, String update); - Result isUpdating(Long examId); + Result forceUnlock(Long examId); + + Result isLocked(Long examId); Result upToDate(Long examId, String lastUpdate); diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ConfigurationDAOImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ConfigurationDAOImpl.java index 4835c7a4..fc11b7f6 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ConfigurationDAOImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ConfigurationDAOImpl.java @@ -21,6 +21,7 @@ import java.util.stream.Collectors; import org.apache.commons.lang3.BooleanUtils; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import ch.ethz.seb.sebserver.gbl.api.EntityType; @@ -148,7 +149,7 @@ public class ConfigurationDAOImpl implements ConfigurationDAO { .execute(); Collections.sort( configs, - (c1, c2) -> c1.getVersionDate().compareTo(c2.getVersionDate())); + (c1, c2) -> c1.getVersionDate().compareTo(c2.getVersionDate()) * -1); final ConfigurationRecord configurationRecord = configs.get(0); return configurationRecord; }).flatMap(ConfigurationDAOImpl::toDomainModel); @@ -177,7 +178,7 @@ public class ConfigurationDAOImpl implements ConfigurationDAO { } @Override - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public Result saveToHistory(final Long configurationNodeId) { return this.configurationDAOBatchService .saveToHistory(configurationNodeId) diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamConfigurationMapDAOImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamConfigurationMapDAOImpl.java index 66f6b931..ad154c83 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamConfigurationMapDAOImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamConfigurationMapDAOImpl.java @@ -173,7 +173,7 @@ public class ExamConfigurationMapDAOImpl implements ExamConfigurationMapDAO { @Override @Transactional(readOnly = true) - public Result getDefaultConfigurationForExam(final Long examId) { + public Result getDefaultConfigurationNode(final Long examId) { return Result.tryCatch(() -> this.examConfigurationMapRecordMapper .selectByExample() .where( @@ -190,7 +190,7 @@ public class ExamConfigurationMapDAOImpl implements ExamConfigurationMapDAO { } @Override - public Result getUserConfigurationIdForExam(final Long examId, final String userId) { + public Result getUserConfigurationNodeId(final Long examId, final String userId) { return Result.tryCatch(() -> this.examConfigurationMapRecordMapper .selectByExample() .where( 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 a82afac4..85d91742 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 @@ -27,6 +27,7 @@ import org.apache.commons.lang3.StringUtils; import org.joda.time.DateTime; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import ch.ethz.seb.sebserver.gbl.Constants; @@ -172,13 +173,14 @@ public class ExamDAOImpl implements ExamDAO { (exam.supporter != null) ? StringUtils.join(exam.supporter, Constants.LIST_SEPARATOR_CHAR) : null, - (exam.type != null) ? exam.type.name() : ExamType.UNDEFINED.name(), + (exam.type != null) ? exam.type.name() : null, exam.quitPassword, null, // browser keys - null, // status + (exam.status != null) ? exam.status.name() : null, null, // updating null, // lastUpdate - BooleanUtils.toIntegerObject(exam.active)); + null // active + ); this.examRecordMapper.updateByPrimaryKeySelective(examRecord); return this.examRecordMapper.selectByPrimaryKey(exam.id); @@ -230,8 +232,8 @@ public class ExamDAOImpl implements ExamDAO { (exam.type != null) ? exam.type.name() : ExamType.UNDEFINED.name(), null, // quitPassword null, // browser keys - null, // status - null, // updating + (exam.status != null) ? exam.status.name() : ExamStatus.UP_COMING.name(), + BooleanUtils.toInteger(false), null, // lastUpdate BooleanUtils.toInteger(true)); @@ -329,8 +331,8 @@ public class ExamDAOImpl implements ExamDAO { } @Override - @Transactional - public Result startUpdate(final Long examId, final String update) { + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Result placeLock(final Long examId, final String update) { return Result.tryCatch(() -> { final ExamRecord examRec = this.recordById(examId) @@ -357,8 +359,8 @@ public class ExamDAOImpl implements ExamDAO { } @Override - @Transactional - public Result endUpdate(final Long examId, final String update) { + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Result releaseLock(final Long examId, final String update) { return Result.tryCatch(() -> { final ExamRecord examRec = this.recordById(examId) @@ -386,9 +388,29 @@ public class ExamDAOImpl implements ExamDAO { .onError(TransactionHandler::rollback); } + @Override + @Transactional + public Result forceUnlock(final Long examId) { + + log.info("forceUnlock for exam: {}", examId); + + return Result.tryCatch(() -> { + final ExamRecord examRecord = new ExamRecord( + examId, + null, null, null, null, null, null, null, null, null, + BooleanUtils.toInteger(false), + null, null); + + this.examRecordMapper.updateByPrimaryKeySelective(examRecord); + return examRecord.getId(); + }) + .onError(TransactionHandler::rollback); + + } + @Override @Transactional(readOnly = true) - public Result isUpdating(final Long examId) { + public Result isLocked(final Long examId) { return this.recordById(examId) .map(rec -> BooleanUtils.toBooleanObject(rec.getUpdating())); } @@ -397,7 +419,13 @@ public class ExamDAOImpl implements ExamDAO { @Transactional(readOnly = true) public Result upToDate(final Long examId, final String lastUpdate) { return this.recordById(examId) - .map(rec -> lastUpdate.equals(rec.getLastupdate())); + .map(rec -> { + if (lastUpdate == null) { + return rec.getLastupdate() == null; + } else { + return lastUpdate.equals(rec.getLastupdate()); + } + }); } @Override @@ -588,7 +616,8 @@ public class ExamDAOImpl implements ExamDAO { record.getOwner(), supporter, status, - BooleanUtils.toBooleanObject((quizData != null) ? record.getActive() : 0)); + BooleanUtils.toBooleanObject((quizData != null) ? record.getActive() : 0), + record.getLastupdate()); }); } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/MockupLmsAPITemplate.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/MockupLmsAPITemplate.java index b419266e..2c541a85 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/MockupLmsAPITemplate.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/MockupLmsAPITemplate.java @@ -15,9 +15,12 @@ import java.util.Set; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import ch.ethz.seb.sebserver.gbl.Constants; import ch.ethz.seb.sebserver.gbl.api.APIMessage; import ch.ethz.seb.sebserver.gbl.model.exam.QuizData; import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup; @@ -53,26 +56,35 @@ final class MockupLmsAPITemplate implements LmsAPITemplate { final LmsType lmsType = lmsSetup.getLmsType(); this.mockups = new ArrayList<>(); this.mockups.add(new QuizData( - "quiz1", institutionId, lmsSetupId, lmsType, "Demo Quiz 1", "Demo Quit Mockup", + "quiz1", institutionId, lmsSetupId, lmsType, "Demo Quiz 1", "Demo Quiz Mockup", "2020-01-01T09:00:00Z", "2021-01-01T09:00:00Z", "http://lms.mockup.com/api/")); this.mockups.add(new QuizData( - "quiz2", institutionId, lmsSetupId, lmsType, "Demo Quiz 2", "Demo Quit Mockup", + "quiz2", institutionId, lmsSetupId, lmsType, "Demo Quiz 2", "Demo Quiz Mockup", "2020-01-01T09:00:00Z", "2021-01-01T09:00:00Z", "http://lms.mockup.com/api/")); this.mockups.add(new QuizData( - "quiz3", institutionId, lmsSetupId, lmsType, "Demo Quiz 3", "Demo Quit Mockup", + "quiz3", institutionId, lmsSetupId, lmsType, "Demo Quiz 3", "Demo Quiz Mockup", "2018-07-30T09:00:00Z", "2018-08-01T00:00:00Z", "http://lms.mockup.com/api/")); this.mockups.add(new QuizData( - "quiz4", institutionId, lmsSetupId, lmsType, "Demo Quiz 4", "Demo Quit Mockup", + "quiz4", institutionId, lmsSetupId, lmsType, "Demo Quiz 4", "Demo Quiz Mockup", "2018-01-01T00:00:00Z", "2019-01-01T00:00:00Z", "http://lms.mockup.com/api/")); this.mockups.add(new QuizData( - "quiz5", institutionId, lmsSetupId, lmsType, "Demo Quiz 5", "Demo Quit Mockup", + "quiz5", institutionId, lmsSetupId, lmsType, "Demo Quiz 5", "Demo Quiz Mockup", "2018-01-01T09:00:00Z", "2021-01-01T09:00:00Z", "http://lms.mockup.com/api/")); this.mockups.add(new QuizData( - "quiz6", institutionId, lmsSetupId, lmsType, "Demo Quiz 6", "Demo Quit Mockup", + "quiz6", institutionId, lmsSetupId, lmsType, "Demo Quiz 6", "Demo Quiz Mockup", "2019-01-01T09:00:00Z", "2021-01-01T09:00:00Z", "http://lms.mockup.com/api/")); this.mockups.add(new QuizData( - "quiz7", institutionId, lmsSetupId, lmsType, "Demo Quiz 7", "Demo Quit Mockup", + "quiz7", institutionId, lmsSetupId, lmsType, "Demo Quiz 7", "Demo Quiz Mockup", "2018-01-01T09:00:00Z", "2021-01-01T09:00:00Z", "http://lms.mockup.com/api/")); + + this.mockups.add(new QuizData( + "quiz10", institutionId, lmsSetupId, lmsType, "Demo Quiz 10", + "Starts in a minute and ends after five minutes", + DateTime.now(DateTimeZone.UTC).plus(Constants.MINUTE_IN_MILLIS) + .toString(Constants.DEFAULT_DATE_TIME_FORMAT), + DateTime.now(DateTimeZone.UTC).plus(6 * Constants.MINUTE_IN_MILLIS) + .toString(Constants.DEFAULT_DATE_TIME_FORMAT), + "http://lms.mockup.com/api/")); } @Override diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/sebconfig/ConfigurationChangedEvent.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/sebconfig/ConfigurationChangedEvent.java deleted file mode 100644 index ee9ed1c0..00000000 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/sebconfig/ConfigurationChangedEvent.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2019 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.sebconfig; - -public class ConfigurationChangedEvent { - - public final Long configurationId; - - public ConfigurationChangedEvent(final Long configurationId) { - this.configurationId = configurationId; - } - -} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/sebconfig/impl/SebExamConfigServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/sebconfig/impl/SebExamConfigServiceImpl.java index db7118d8..243a0c08 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/sebconfig/impl/SebExamConfigServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/sebconfig/impl/SebExamConfigServiceImpl.java @@ -147,11 +147,11 @@ public class SebExamConfigServiceImpl implements SebExamConfigService { } public Result getDefaultConfigurationIdForExam(final Long examId) { - return this.examConfigurationMapDAO.getDefaultConfigurationForExam(examId); + return this.examConfigurationMapDAO.getDefaultConfigurationNode(examId); } public Result getUserConfigurationIdForExam(final Long examId, final String userId) { - return this.examConfigurationMapDAO.getUserConfigurationIdForExam(examId, userId); + return this.examConfigurationMapDAO.getUserConfigurationNodeId(examId, userId); } @Override diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamConfigUpdateService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamConfigUpdateService.java new file mode 100644 index 00000000..e19be905 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamConfigUpdateService.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2019 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 java.util.Collection; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.transaction.annotation.Transactional; + +import ch.ethz.seb.sebserver.gbl.model.sebconfig.Configuration; +import ch.ethz.seb.sebserver.gbl.util.Result; + +public interface ExamConfigUpdateService { + + Logger log = LoggerFactory.getLogger(ExamConfigUpdateService.class); + + Result> processSEBExamConfigurationChange(Long configurationNodeId); + + @Transactional + default Result processSEBExamConfigurationChange(final Configuration config) { + if (config == null) { + return Result.ofError(new NullPointerException("Configuration has null reference")); + } + + return Result.tryCatch(() -> { + processSEBExamConfigurationChange(config.configurationNodeId) + .map(ids -> { + log.info("Successfully updated SEB Configuration for exams: {}", ids); + return ids; + }) + .getOrThrow(); + return config; + }); + } + + void forceReleaseUpdateLocks(Long configurationId); + + Collection> forceReleaseUpdateLocks(Collection examIds); + + Result> checkRunningExamIntegrity(final Long configurationNodeId); + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamSessionService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamSessionService.java index 2a190244..6849cdfb 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamSessionService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamSessionService.java @@ -12,15 +12,12 @@ import java.io.OutputStream; import java.util.Collection; import java.util.function.Predicate; -import org.springframework.context.event.EventListener; - import ch.ethz.seb.sebserver.gbl.api.APIMessage; import ch.ethz.seb.sebserver.gbl.model.exam.Exam; import ch.ethz.seb.sebserver.gbl.model.session.ClientConnectionData; import ch.ethz.seb.sebserver.gbl.util.Result; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ExamDAO; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap; -import ch.ethz.seb.sebserver.webservice.servicelayer.sebconfig.ConfigurationChangedEvent; /** A Service to handle running exam sessions */ public interface ExamSessionService { @@ -47,6 +44,12 @@ public interface ExamSessionService { * @return true if an Exam is currently running */ boolean isExamRunning(Long examId); + /** Indicates if the Exam with specified Id is currently locked for new SEB Client connection attempts. + * + * @param examId The Exam identifier + * @return true if the specified Exam is currently locked for new SEB Client connections. */ + boolean isExamLocked(Long examId); + /** Use this to get currently running exams by exam identifier. * This test first if the Exam with the given identifier is currently/still * running. If true the Exam is returned within the result. Otherwise the @@ -99,7 +102,17 @@ public interface ExamSessionService { * of a running exam */ Result> getConnectionData(Long examId); - @EventListener(ConfigurationChangedEvent.class) - void updateExamConfigCache(ConfigurationChangedEvent configChanged); + /** Use this to check if the current cached running exam is up to date + * and if not to flush the cache. + * + * @param examId the Exam identifier + * @return Result with updated Exam instance or refer to an error if happened */ + Result updateExamCache(Long examId); + + /** Flush all the caches for an specified Exam. + * + * @param exam The Exam instance + * @return Result with reference to the given Exam or to an error if happened */ + Result flushCache(final Exam exam); } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamConfigUpdateServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamConfigUpdateServiceImpl.java new file mode 100644 index 00000000..9f2f68f7 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamConfigUpdateServiceImpl.java @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2019 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.impl; + +import java.util.Collection; +import java.util.Collections; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ch.ethz.seb.sebserver.gbl.Constants; +import ch.ethz.seb.sebserver.gbl.api.APIMessage; +import ch.ethz.seb.sebserver.gbl.api.APIMessage.ErrorMessage; +import ch.ethz.seb.sebserver.gbl.model.exam.Exam; +import ch.ethz.seb.sebserver.gbl.model.sebconfig.Configuration; +import ch.ethz.seb.sebserver.gbl.model.session.ClientConnection.ConnectionStatus; +import ch.ethz.seb.sebserver.gbl.model.session.ClientConnectionData; +import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; +import ch.ethz.seb.sebserver.gbl.util.Result; +import ch.ethz.seb.sebserver.gbl.util.Utils; +import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ConfigurationDAO; +import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ExamConfigurationMapDAO; +import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ExamDAO; +import ch.ethz.seb.sebserver.webservice.servicelayer.dao.TransactionHandler; +import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamConfigUpdateService; +import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamSessionService; + +@Lazy +@Service +@WebServiceProfile +public class ExamConfigUpdateServiceImpl implements ExamConfigUpdateService { + + private static final Logger log = LoggerFactory.getLogger(ExamConfigUpdateServiceImpl.class); + + private final ExamDAO examDAO; + private final ConfigurationDAO configurationDAO; + private final ExamConfigurationMapDAO examConfigurationMapDAO; + private final ExamSessionService examSessionService; + private final ExamSessionControlTask examSessionControlTask; + + protected ExamConfigUpdateServiceImpl( + final ExamDAO examDAO, + final ConfigurationDAO configurationDAO, + final ExamConfigurationMapDAO examConfigurationMapDAO, + final ExamSessionService examSessionService, + final ExamSessionControlTask examSessionControlTask) { + + this.examDAO = examDAO; + this.configurationDAO = configurationDAO; + this.examConfigurationMapDAO = examConfigurationMapDAO; + this.examSessionService = examSessionService; + this.examSessionControlTask = examSessionControlTask; + } + + // processing: + // check running exam integrity (No running exam with active SEB client-connection available) + // if OK, create an update-id and for each exam, create an update-lock on DB (This also prevents new SEB client connection attempts during update) + // check running exam integrity again after lock to ensure there where no SEB Client connection attempts in the meantime + // store the new configuration values (into history) so that they take effect + // generate the new Config Key and update the Config Key within the LMSSetup API for each exam (delete old Key and add new Key) + // evict each Exam from cache and release the update-lock on DB + @Override + @Transactional + public Result> processSEBExamConfigurationChange(final Long configurationNodeId) { + + final String updateId = this.examSessionControlTask.createUpdateId(); + + if (log.isDebugEnabled()) { + log.debug("Process SEB Exam Configuration update for: {} with update-id {}", + configurationNodeId, + updateId); + } + + return Result.tryCatch(() -> { + + // check running exam integrity (No running exam with active SEB client-connection available) + final Collection examIdsFirstCheck = checkRunningExamIntegrity(configurationNodeId) + .getOrThrow(); + + if (log.isDebugEnabled()) { + log.debug("Involved exams on fist integrity check: {}", examIdsFirstCheck); + } + + // if OK, create an update-lock on DB (This also prevents new SEB client connection attempts during update) + final Collection exams = lockForUpdate(examIdsFirstCheck, updateId) + .stream() + .map(Result::getOrThrow) + .collect(Collectors.toList()); + + final Collection examsIds = exams + .stream() + .map(Exam::getId) + .collect(Collectors.toList()); + + if (log.isDebugEnabled()) { + log.debug("Update-Lock successfully placed for all involved exams: {}", examsIds); + } + + // check running exam integrity again after lock to ensure there where no SEB Client connection attempts in the meantime + final Collection examIdsSecondCheck = checkRunningExamIntegrity(configurationNodeId) + .getOrThrow(); + + checkIntegrityDoubleCheck( + examIdsFirstCheck, + examIdsSecondCheck); + + if (log.isDebugEnabled()) { + log.debug("Involved exams on second integrity check: {}", examIdsSecondCheck); + } + + // store the new configuration values (into history) so that they take effect + final Configuration configuration = this.configurationDAO + .saveToHistory(configurationNodeId) + .getOrThrow(); + + if (log.isDebugEnabled()) { + log.debug("Successfully save SEB Exam Configuration: {}", configuration); + } + + // generate the new Config Key and update the Config Key within the LMSSetup API for each exam (delete old Key and add new Key) + final Collection updatedExams = updateConfigKey(exams) + .stream() + .map(Result::getOrThrow) + .map(Exam::getId) + .collect(Collectors.toList()); + + if (log.isDebugEnabled()) { + log.debug("Successfully updated ConfigKey for Exams: {}", updatedExams); + } + + // evict each Exam from cache and release the update-lock on DB + final Collection evictedExams = evictFromCache(exams) + .stream() + .filter(Result::hasValue) + .map(Result::get) + .map(Exam::getId) + .collect(Collectors.toList()); + + if (log.isDebugEnabled()) { + log.debug("Successfully evicted Exams from cache: {}", evictedExams); + } + + // release the update-locks on involved exams + final Collection releasedLocks = releaseUpdateLocks(examIdsFirstCheck, updateId) + .stream() + .map(Result::getOrThrow) + .map(Exam::getId) + .collect(Collectors.toList()); + + if (log.isDebugEnabled()) { + log.debug("Successfully released update-locks on Exams: {}", releasedLocks); + } + + return examIdsFirstCheck; + }) + .onError(TransactionHandler::rollback); + } + + @Override + public void forceReleaseUpdateLocks(final Long configurationId) { + + log.warn(" **** Force release of update-locks for all exams that are related to configuration: {}", + configurationId); + + try { + final Configuration config = this.configurationDAO.byPK(configurationId) + .getOrThrow(); + + final Collection involvedExams = this.examConfigurationMapDAO + .getExamIdsForConfigNodeId(config.configurationNodeId) + .getOrThrow(); + + final Collection examsIds = forceReleaseUpdateLocks(involvedExams) + .stream() + .map(Result::getOrThrow) + .collect(Collectors.toList()); + + log.info("Successfully released update-locks for exams: {}", examsIds); + + } catch (final Exception e) { + log.error("Failed to release update-locks for exam(s)", e); + } + + } + + @Override + public Collection> forceReleaseUpdateLocks(final Collection examIds) { + return examIds.stream() + .map(this.examDAO::forceUnlock) + .collect(Collectors.toList()); + } + + private void checkIntegrityDoubleCheck( + final Collection examIdsFirstCheck, + final Collection examIdsSecondCheck) { + + if (examIdsFirstCheck.size() != examIdsSecondCheck.size()) { + throw new IllegalStateException("Running Exam integrity check missmatch. examIdsFirstCheck: " + + examIdsFirstCheck + " examIdsSecondCheck: " + examIdsSecondCheck); + } + } + + private Collection> lockForUpdate(final Collection examIds, final String update) { + return examIds.stream() + .map(id -> this.examDAO.placeLock(id, update)) + .collect(Collectors.toList()); + } + + private Collection> releaseUpdateLocks(final Collection examIds, final String update) { + return examIds.stream() + .map(id -> this.examDAO.releaseLock(id, update)) + .collect(Collectors.toList()); + } + + private Collection> updateConfigKey(final Collection exams) { + return exams + .stream() + .map(this::updateConfigKey) + .collect(Collectors.toList()); + } + + private Result updateConfigKey(final Exam exam) { + return Result.tryCatch(() -> { + + if (log.isDebugEnabled()) { + log.debug("update Config Key for Exam {}", exam.externalId); + } + + // TODO + + return exam; + }); + } + + private Collection> evictFromCache(final Collection exams) { + return exams + .stream() + .map(this.examSessionService::flushCache) + .collect(Collectors.toList()); + } + + @Override + public Result> checkRunningExamIntegrity(final Long configurationNodeId) { + final Collection involvedExams = this.examConfigurationMapDAO + .getExamIdsForConfigNodeId(configurationNodeId) + .getOrThrow(); + + if (involvedExams == null || involvedExams.isEmpty()) { + return Result.of(Collections.emptyList()); + } + + // check if the configuration is attached to a running exams with active client connections + final long activeConnections = involvedExams + .stream() + .flatMap(examId -> { + return this.examSessionService.getConnectionData(examId) + .getOrThrow() + .stream(); + }) + .filter(ExamConfigUpdateServiceImpl::isActiveConnection) + .count(); + + // if we have active SEB client connection on any running exam that + // is involved within the specified configuration change, the change is denied + if (activeConnections > 0) { + return Result.ofError(new APIMessage.APIMessageException( + ErrorMessage.INTEGRITY_VALIDATION, + "Integrity violation: There are currently active SEB Client connection.")); + } else { + // otherwise we return the involved identifiers exams to further processing + return Result.of(involvedExams); + } + } + + private static boolean isActiveConnection(final ClientConnectionData connection) { + if (connection.clientConnection.status == ConnectionStatus.ESTABLISHED + || connection.clientConnection.status == ConnectionStatus.AUTHENTICATED) { + return true; + } + + if (connection.clientConnection.status == ConnectionStatus.CONNECTION_REQUESTED) { + final Long creationTime = connection.clientConnection.getCreationTime(); + final long millisecondsNow = Utils.getMillisecondsNow(); + if (millisecondsNow - creationTime < 30 * Constants.SECOND_IN_MILLIS) { + return true; + } + } + + return false; + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionCacheService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionCacheService.java index 80e94696..8578d049 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionCacheService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionCacheService.java @@ -100,8 +100,7 @@ public class ExamSessionCacheService { @CacheEvict( cacheNames = CACHE_NAME_RUNNING_EXAM, - key = "#exam.id", - condition = "#target.isRunning(#result)") + key = "#exam.id") public Exam evict(final Exam exam) { if (log.isDebugEnabled()) { @@ -111,7 +110,7 @@ public class ExamSessionCacheService { return exam; } - boolean isRunning(final Exam exam) { + public boolean isRunning(final Exam exam) { if (exam == null) { return false; } @@ -131,7 +130,6 @@ public class ExamSessionCacheService { default: { return false; } - } } @@ -166,33 +164,31 @@ public class ExamSessionCacheService { @Cacheable( cacheNames = CACHE_NAME_SEB_CONFIG_EXAM, - key = "#examId", + key = "#exam.id", unless = "#result == null") - public InMemorySebConfig getDefaultSebConfigForExam(final Long examId) { - final Exam runningExam = this.getRunningExam(examId); - + public InMemorySebConfig getDefaultSebConfigForExam(final Exam exam) { try { final ByteArrayOutputStream byteOut = new ByteArrayOutputStream(); final Long configId = this.sebExamConfigService.exportForExam( byteOut, - runningExam.institutionId, - examId); + exam.institutionId, + exam.id); - return new InMemorySebConfig(configId, runningExam.id, byteOut.toByteArray()); + return new InMemorySebConfig(configId, exam.id, byteOut.toByteArray()); } catch (final Exception e) { - log.error("Unexpected error while getting default exam configuration for running exam; {}", runningExam, e); + log.error("Unexpected error while getting default exam configuration for running exam; {}", exam, e); return null; } } @CacheEvict( cacheNames = CACHE_NAME_SEB_CONFIG_EXAM, - key = "#examId") - public void evictDefaultSebConfig(final Long examId) { + key = "#exam.id") + public void evictDefaultSebConfig(final Exam exam) { if (log.isDebugEnabled()) { - log.debug("Eviction of default SEB Configuration from cache for exam: {}", examId); + log.debug("Eviction of default SEB Configuration from cache for exam: {}", exam.id); } } 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 42acdfa5..04cc03ea 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 @@ -29,7 +29,7 @@ import ch.ethz.seb.sebserver.webservice.WebserviceInfo; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ExamDAO; @Service -public class ExamSessionControlTask { +class ExamSessionControlTask { private static final Logger log = LoggerFactory.getLogger(ExamSessionControlTask.class); @@ -47,20 +47,19 @@ public class ExamSessionControlTask { this.examDAO = examDAO; this.examTimePrefix = examTimePrefix; this.examTimeSuffix = examTimeSuffix; - this.updatePrefix = webserviceInfo.getHostAddress() + "_" + webserviceInfo.getServerPort() + "_"; } @Async - @Scheduled(cron = "1 * * * * *") + @Scheduled(cron = "${sebserver.webservice.api.exam.update-interval:1 * * * * *}") @Transactional public void execTask() { - final String updateId = createUpdateId(); + final String updateId = this.createUpdateId(); if (log.isDebugEnabled()) { - log.debug("Run ExamControlTask. Update Id: {}", updateId); + log.debug("Run exam runtime update task with Id: {}", updateId); } controlStart(updateId); @@ -84,7 +83,9 @@ public class ExamSessionControlTask { .map(exam -> this.setRunning(exam, updateId)) .collect(Collectors.toMap(Exam::getId, Exam::getName)); - log.info("Updated exams to running state: {}", updated); + 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); @@ -97,15 +98,15 @@ public class ExamSessionControlTask { final DateTime now = DateTime.now(DateTimeZone.UTC); if (exam.getStatus() == ExamStatus.UP_COMING && exam.endTime.plus(this.examTimeSuffix).isBefore(now)) { - return setRunning(exam, createUpdateId()); + return setRunning(exam, this.createUpdateId()); } else { return exam; } }); } - public Result setRunning(final Exam exam) { - return Result.tryCatch(() -> setRunning(exam, createUpdateId())); + public String createUpdateId() { + return this.updatePrefix + Utils.getMillisecondsNow(); } private Exam setRunning(final Exam exam, final String updateId) { @@ -114,11 +115,11 @@ public class ExamSessionControlTask { } return this.examDAO - .startUpdate(exam.id, updateId) + .placeLock(exam.id, updateId) .flatMap(e -> this.examDAO.save(new Exam( exam.id, ExamStatus.RUNNING))) - .flatMap(e -> this.examDAO.endUpdate(e.id, updateId)) + .flatMap(e -> this.examDAO.releaseLock(e.id, updateId)) .getOrThrow(); } @@ -138,7 +139,9 @@ public class ExamSessionControlTask { .map(exam -> this.setFinished(exam, updateId)) .collect(Collectors.toMap(Exam::getId, Exam::getName)); - log.info("Updated exams to finished state: {}", updated); + 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); @@ -151,16 +154,12 @@ public class ExamSessionControlTask { } return this.examDAO - .startUpdate(exam.id, updateId) + .placeLock(exam.id, updateId) .flatMap(e -> this.examDAO.save(new Exam( exam.id, ExamStatus.FINISHED))) - .flatMap(e -> this.examDAO.endUpdate(e.id, updateId)) + .flatMap(e -> this.examDAO.releaseLock(e.id, updateId)) .getOrThrow(); } - private String createUpdateId() { - return this.updatePrefix + Utils.getMillisecondsNow(); - } - } 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 777f0504..9770b64a 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 @@ -17,12 +17,12 @@ import java.util.NoSuchElementException; import java.util.function.Predicate; import java.util.stream.Collectors; +import org.apache.commons.lang3.BooleanUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.context.annotation.Lazy; -import org.springframework.context.event.EventListener; import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; @@ -39,7 +39,6 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ExamConfigurationMapDAO import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ExamDAO; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.IndicatorDAO; -import ch.ethz.seb.sebserver.webservice.servicelayer.sebconfig.ConfigurationChangedEvent; import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamSessionService; @Lazy @@ -92,7 +91,7 @@ public class ExamSessionServiceImpl implements ExamSessionService { } // check SEB configuration - this.examConfigurationMapDAO.getDefaultConfigurationForExam(examId) + this.examConfigurationMapDAO.getDefaultConfigurationNode(examId) .get(t -> { result.add(ErrorMessage.EXAM_CONSISTANCY_VALIDATION_CONFIG.of(exam.getModelId())); return null; @@ -116,6 +115,17 @@ public class ExamSessionServiceImpl implements ExamSessionService { return !getRunningExam(examId).hasError(); } + @Override + public boolean isExamLocked(final Long examId) { + final Result locked = this.examDAO.isLocked(examId); + + if (locked.hasError()) { + log.error("Unexpected Error while trying to verify lock for Exam: {}", examId); + } + + return locked.hasError() || BooleanUtils.toBoolean(locked.get()); + } + @Override public Result getRunningExam(final Long examId) { if (log.isTraceEnabled()) { @@ -123,6 +133,7 @@ public class ExamSessionServiceImpl implements ExamSessionService { } final Exam exam = this.examSessionCacheService.getRunningExam(examId); + if (this.examSessionCacheService.isRunning(exam)) { if (log.isTraceEnabled()) { log.trace("Exam {} is running and cached", examId); @@ -194,8 +205,11 @@ public class ExamSessionServiceImpl implements ExamSessionService { log.debug("Trying to get exam from InMemorySebConfig"); } + final Exam exam = this.getRunningExam(connection.examId) + .getOrThrow(); + final InMemorySebConfig sebConfigForExam = this.examSessionCacheService - .getDefaultSebConfigForExam(connection.examId); + .getDefaultSebConfigForExam(exam); if (sebConfigForExam == null) { log.error("Failed to get and cache InMemorySebConfig for connection: {}", connection); @@ -241,23 +255,24 @@ public class ExamSessionServiceImpl implements ExamSessionService { } @Override - @EventListener(ConfigurationChangedEvent.class) - public void updateExamConfigCache(final ConfigurationChangedEvent configChanged) { + public Result updateExamCache(final Long examId) { + final Exam exam = this.examSessionCacheService.getRunningExam(examId); + final Boolean isUpToDate = this.examDAO.upToDate(examId, exam.lastUpdate) + .onError(t -> log.error("Failed to verify if cached exam is up to date: {}", exam, t)) + .getOr(false); - if (log.isDebugEnabled()) { - log.debug("Flush exam config cache for configuration: {}", configChanged.configurationId); + if (!BooleanUtils.toBoolean(isUpToDate)) { + return flushCache(exam); + } else { + return Result.of(exam); } - - this.examConfigurationMapDAO - .getExamIdsForConfigId(configChanged.configurationId) - .getOrElse(() -> Collections.emptyList()) - .forEach(this.examSessionCacheService::evictDefaultSebConfig); } - private void flushCache(final Exam exam) { - try { + @Override + public Result flushCache(final Exam exam) { + return Result.tryCatch(() -> { this.examSessionCacheService.evict(exam); - this.examSessionCacheService.evictDefaultSebConfig(exam.id); + this.examSessionCacheService.evictDefaultSebConfig(exam); this.clientConnectionDAO .getConnectionTokens(exam.id) .getOrElse(() -> Collections.emptyList()) @@ -267,9 +282,9 @@ public class ExamSessionServiceImpl implements ExamSessionService { // evict also cached ping record this.examSessionCacheService.evictPingRecord(token); }); - } catch (final Exception e) { - log.error("Unexpected error while trying to flush cache for exam: ", exam, e); - } + + return exam; + }); } } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/SebClientConnectionServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/SebClientConnectionServiceImpl.java index 5ce63692..982e75eb 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/SebClientConnectionServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/SebClientConnectionServiceImpl.java @@ -18,6 +18,7 @@ import org.springframework.boot.logging.LogLevel; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +import ch.ethz.seb.sebserver.gbl.model.exam.Exam; import ch.ethz.seb.sebserver.gbl.model.exam.Exam.ExamType; import ch.ethz.seb.sebserver.gbl.model.session.ClientConnection; import ch.ethz.seb.sebserver.gbl.model.session.ClientConnection.ConnectionStatus; @@ -101,7 +102,9 @@ public class SebClientConnectionServiceImpl implements SebClientConnectionServic clientAddress); } - checkExamRunning(examId); + if (examId != null) { + checkExamIntegrity(examId); + } // Create ClientConnection in status CONNECTION_REQUESTED for further processing final String connectionToken = createToken(); @@ -133,6 +136,20 @@ public class SebClientConnectionServiceImpl implements SebClientConnectionServic }); } + private void checkExamIntegrity(final Long examId) { + // check Exam is running and not locked + checkExamRunning(examId); + if (this.examSessionService.isExamLocked(examId)) { + throw new APIConstraintViolationException( + "Exam is currently on update and locked for new SEB Client connections"); + } + // if the cached Exam is not up to date anymore, we have to update the cache first + final Result updateExamCache = this.examSessionService.updateExamCache(examId); + if (updateExamCache.hasError()) { + log.warn("Failed to update Exam-Cache for Exam: {}", examId); + } + } + @Override public Result updateClientConnection( final String connectionToken, @@ -170,6 +187,10 @@ public class SebClientConnectionServiceImpl implements SebClientConnectionServic "ClientConnection integrity violation: client connection is not in expected state"); } + if (examId != null) { + checkExamIntegrity(examId); + } + // userSessionId integrity check if (userSessionId != null && clientConnection.userSessionId != null && @@ -294,6 +315,8 @@ public class SebClientConnectionServiceImpl implements SebClientConnectionServic .save(establishedClientConnection) .getOrThrow(); + checkExamIntegrity(updatedClientConnection.examId); + // evict cached ClientConnection this.examSessionCacheService.evictClientConnection(connectionToken); // and load updated ClientConnection into cache diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ConfigurationController.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ConfigurationController.java index 4a64b9e9..1d6097a6 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ConfigurationController.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ConfigurationController.java @@ -11,7 +11,6 @@ package ch.ethz.seb.sebserver.webservice.weblayer.api; import java.util.Collection; import org.mybatis.dynamic.sql.SqlTable; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -19,27 +18,18 @@ import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import ch.ethz.seb.sebserver.gbl.Constants; import ch.ethz.seb.sebserver.gbl.api.API; import ch.ethz.seb.sebserver.gbl.api.API.BulkActionType; -import ch.ethz.seb.sebserver.gbl.api.APIMessage; -import ch.ethz.seb.sebserver.gbl.api.APIMessage.ErrorMessage; import ch.ethz.seb.sebserver.gbl.model.EntityKey; import ch.ethz.seb.sebserver.gbl.model.sebconfig.Configuration; -import ch.ethz.seb.sebserver.gbl.model.session.ClientConnection.ConnectionStatus; -import ch.ethz.seb.sebserver.gbl.model.session.ClientConnectionData; import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; -import ch.ethz.seb.sebserver.gbl.util.Result; -import ch.ethz.seb.sebserver.gbl.util.Utils; import ch.ethz.seb.sebserver.webservice.datalayer.batis.mapper.ConfigurationRecordDynamicSqlSupport; import ch.ethz.seb.sebserver.webservice.servicelayer.PaginationService; import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.AuthorizationService; import ch.ethz.seb.sebserver.webservice.servicelayer.bulkaction.BulkActionService; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ConfigurationDAO; -import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ExamConfigurationMapDAO; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.UserActivityLogDAO; -import ch.ethz.seb.sebserver.webservice.servicelayer.sebconfig.ConfigurationChangedEvent; -import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamSessionService; +import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamConfigUpdateService; import ch.ethz.seb.sebserver.webservice.servicelayer.validation.BeanValidationService; @WebServiceProfile @@ -48,9 +38,7 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.validation.BeanValidationSe public class ConfigurationController extends ReadonlyEntityController { private final ConfigurationDAO configurationDAO; - private final ExamConfigurationMapDAO examConfigurationMapDAO; - private final ApplicationEventPublisher applicationEventPublisher; - private final ExamSessionService examSessionService; + private final ExamConfigUpdateService examConfigUpdateService; protected ConfigurationController( final AuthorizationService authorization, @@ -59,9 +47,7 @@ public class ConfigurationController extends ReadonlyEntityController this.configurationDAO.saveToHistory(config.configurationNodeId)) + .flatMap(this.examConfigUpdateService::processSEBExamConfigurationChange) + .onError(t -> this.examConfigUpdateService.forceReleaseUpdateLocks(modelId)) .flatMap(this.userActivityLogDAO::logSaveToHistory) - .flatMap(this::publishConfigChanged) .getOrThrow(); } @@ -103,7 +86,6 @@ public class ConfigurationController extends ReadonlyEntityController this.configurationDAO.undo(config.configurationNodeId)) .flatMap(this.userActivityLogDAO::logUndo) - .flatMap(this::publishConfigChanged) .getOrThrow(); } @@ -119,7 +101,6 @@ public class ConfigurationController extends ReadonlyEntityController this.configurationDAO.restoreToVersion(configurationNodeId, config.getId())) - .flatMap(this::publishConfigChanged) .getOrThrow(); } @@ -133,55 +114,4 @@ public class ConfigurationController extends ReadonlyEntityController publishConfigChanged(final Configuration config) { - this.applicationEventPublisher.publishEvent(new ConfigurationChangedEvent(config.id)); - return Result.of(config); - } - - private Result checkRunningExamIntegrity(final Configuration config) { - // check if the configuration is attached to an exam - final long activeConnections = this.examConfigurationMapDAO - .getExamIdsForConfigNodeId(config.configurationNodeId) - .getOrThrow() - .stream() - .flatMap(examId -> { - return this.examSessionService - .getConnectionData(examId) - .getOrThrow() - .stream(); - }) - .filter(this::isActiveConnection) - .count(); - - if (activeConnections > 0) { - throw new APIMessage.APIMessageException( - ErrorMessage.INTEGRITY_VALIDATION, - "Integrity violation: There are currently active SEB Client connection."); - } else { - return Result.of(config); - } - } - - private boolean isActiveConnection(final ClientConnectionData connection) { - if (connection.clientConnection.status == ConnectionStatus.ESTABLISHED - || connection.clientConnection.status == ConnectionStatus.AUTHENTICATED) { - return true; - } - - if (connection.clientConnection.status == ConnectionStatus.CONNECTION_REQUESTED) { - final Long creationTime = connection.clientConnection.getCreationTime(); - final long millisecondsNow = Utils.getMillisecondsNow(); - if (millisecondsNow - creationTime < 30 * Constants.SECOND_IN_MILLIS) { - return true; - } - } - - return false; - } - } diff --git a/src/main/resources/config/application-dev-ws.properties b/src/main/resources/config/application-dev-ws.properties index 2e0ece9d..b189e9df 100644 --- a/src/main/resources/config/application-dev-ws.properties +++ b/src/main/resources/config/application-dev-ws.properties @@ -27,8 +27,9 @@ sebserver.webservice.http.redirect.gui=/gui sebserver.webservice.api.admin.endpoint=/admin-api/v1 sebserver.webservice.api.admin.accessTokenValiditySeconds=3600 sebserver.webservice.api.admin.refreshTokenValiditySeconds=-1 -sebserver.webservice.api.exam.time-prefix=3600000 -sebserver.webservice.api.exam.time-suffix=3600000 +sebserver.webservice.api.exam.update-interval=1 * * * * * +sebserver.webservice.api.exam.time-prefix=0 +sebserver.webservice.api.exam.time-suffix=0 sebserver.webservice.api.exam.endpoint=/exam-api sebserver.webservice.api.exam.endpoint.discovery=${sebserver.webservice.api.exam.endpoint}/discovery sebserver.webservice.api.exam.endpoint.v1=${sebserver.webservice.api.exam.endpoint}/v1 diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 267661cf..a2ad5ef3 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -448,6 +448,7 @@ sebserver.examconfig.action.modify.properties=Edit Configuration sebserver.examconfig.action.save=Save sebserver.examconfig.action.saveToHistory=Save / Publish sebserver.examconfig.action.saveToHistory.success=Successfully saved in history +sebserver.examconfig.action.saveToHistory.integrity-violation=There is currently at least one running Exam with active SEB client connections that uses this Configuration.
Modify of a configuration that is currently in use would lead to inconsistency and is therefore not allowed.

Please make sure that the configuration is not in use before applying changes. sebserver.examconfig.action.undo=Undo sebserver.examconfig.action.undo.success=Successfully reverted to last saved state sebserver.examconfig.action.copy=Copy Configuration @@ -486,6 +487,8 @@ sebserver.examconfig.props.form.views.security=Security sebserver.examconfig.props.form.views.registry=Registry sebserver.examconfig.props.form.views.hooked_keys=Hooked Keys + + sebserver.examconfig.props.label.hashedAdminPassword=Administrator password sebserver.examconfig.props.label.hashedAdminPassword.confirm=Confirm password sebserver.examconfig.props.label.allowQuit=Allow user to quit SEB diff --git a/src/test/java/ch/ethz/seb/sebserver/gui/integration/UseCasesIntegrationTest.java b/src/test/java/ch/ethz/seb/sebserver/gui/integration/UseCasesIntegrationTest.java index 356763af..a3265c0c 100644 --- a/src/test/java/ch/ethz/seb/sebserver/gui/integration/UseCasesIntegrationTest.java +++ b/src/test/java/ch/ethz/seb/sebserver/gui/integration/UseCasesIntegrationTest.java @@ -706,7 +706,8 @@ public class UseCasesIntegrationTest extends GuiIntegrationTest { null, null, Utils.immutableCollectionOf(userId), ExamStatus.RUNNING, - true); + true, + null); final Result savedExamResult = restService .getBuilder(SaveExam.class) diff --git a/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/ExamAPITest.java b/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/ExamAPITest.java index 2d3e66ca..fba467b2 100644 --- a/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/ExamAPITest.java +++ b/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/ExamAPITest.java @@ -65,7 +65,8 @@ public class ExamAPITest extends AdministrationAPIIntegrationTester { exam.owner, Arrays.asList("user5"), null, - true)) + true, + null)) .withExpectedStatus(HttpStatus.OK) .getAsObject(new TypeReference() { }); @@ -94,7 +95,8 @@ public class ExamAPITest extends AdministrationAPIIntegrationTester { exam.owner, Arrays.asList("user2"), null, - true)) + true, + null)) .withExpectedStatus(HttpStatus.BAD_REQUEST) .getAsObject(new TypeReference>() { }); diff --git a/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/QuizDataTest.java b/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/QuizDataTest.java index 459fa67b..d653fef0 100644 --- a/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/QuizDataTest.java +++ b/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/QuizDataTest.java @@ -59,7 +59,7 @@ public class QuizDataTest extends AdministrationAPIIntegrationTester { }); assertNotNull(quizzes); - assertTrue(quizzes.content.size() == 7); + assertTrue(quizzes.content.size() == 8); // for the inactive LmsSetup we should'nt get any quizzes quizzes = new RestAPITestHelper() @@ -109,7 +109,7 @@ public class QuizDataTest extends AdministrationAPIIntegrationTester { }); assertNotNull(quizzes); - assertTrue(quizzes.content.size() == 7); + assertTrue(quizzes.content.size() == 8); // but for the now active lmsSetup2 we should get the quizzes quizzes = new RestAPITestHelper() @@ -120,7 +120,7 @@ public class QuizDataTest extends AdministrationAPIIntegrationTester { }); assertNotNull(quizzes); - assertTrue(quizzes.content.size() == 7); + assertTrue(quizzes.content.size() == 8); } @Test