From 960864e58fb9816980743c1b891bb72657beb1ee Mon Sep 17 00:00:00 2001 From: anhefti Date: Mon, 23 May 2022 14:42:20 +0200 Subject: [PATCH] SEBSERV-308 added archived state --- .../ch/ethz/seb/sebserver/gbl/api/API.java | 1 + .../gui/content/action/ActionDefinition.java | 5 + .../sebserver/gui/content/exam/ExamForm.java | 19 ++ .../sebserver/gui/content/exam/ExamList.java | 15 ++ .../content/monitoring/FinishedExamList.java | 15 ++ .../gui/service/ResourceService.java | 27 +++ .../webservice/api/exam/ArchiveExam.java | 42 ++++ .../sebserver/gui/widget/WidgetFactory.java | 1 + .../webservice/servicelayer/dao/ExamDAO.java | 10 +- .../servicelayer/dao/impl/ExamDAOImpl.java | 204 +++++++----------- .../servicelayer/dao/impl/ExamRecordDAO.java | 54 ++++- .../session/impl/ExamSessionServiceImpl.java | 7 +- .../impl/SEBClientConnectionServiceImpl.java | 40 +++- .../weblayer/api/APIExceptionHandler.java | 3 +- .../api/ExamAdministrationController.java | 31 +++ src/main/resources/messages.properties | 7 + src/main/resources/static/images/archive.png | Bin 0 -> 139 bytes .../integration/UseCasesIntegrationTest.java | 3 +- .../api/exam/SebConnectionTest.java | 2 +- 19 files changed, 327 insertions(+), 159 deletions(-) create mode 100644 src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/exam/ArchiveExam.java create mode 100644 src/main/resources/static/images/archive.png diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java b/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java index 7c006678..715e117f 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java @@ -145,6 +145,7 @@ public final class API { public static final String EXAM_ADMINISTRATION_ENDPOINT = "/exam"; //public static final String EXAM_ADMINISTRATION_DOWNLOAD_CONFIG_PATH_SEGMENT = "/download-config"; + public static final String EXAM_ADMINISTRATION_ARCHIVE_PATH_SEGMENT = "/archive"; public static final String EXAM_ADMINISTRATION_CONSISTENCY_CHECK_PATH_SEGMENT = "/check-consistency"; public static final String EXAM_ADMINISTRATION_CONSISTENCY_CHECK_INCLUDE_RESTRICTION = "include-restriction"; public static final String EXAM_ADMINISTRATION_SEB_RESTRICTION_PATH_SEGMENT = "/seb-restriction"; diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/action/ActionDefinition.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/action/ActionDefinition.java index b6f8a2bb..ec138370 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/action/ActionDefinition.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/action/ActionDefinition.java @@ -291,6 +291,11 @@ public enum ActionDefinition { ImageIcon.DELETE, PageStateDefinitionImpl.EXAM_VIEW, ActionCategory.FORM), + EXAM_ARCHIVE( + new LocTextKey("sebserver.exam.action.archive"), + ImageIcon.ARCHIVE, + PageStateDefinitionImpl.EXAM_VIEW, + ActionCategory.FORM), EXAM_MODIFY_SEB_RESTRICTION_DETAILS( new LocTextKey("sebserver.exam.action.sebrestriction.details"), diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/exam/ExamForm.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/exam/ExamForm.java index c1906f62..d177701e 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/exam/ExamForm.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/exam/ExamForm.java @@ -61,6 +61,7 @@ import ch.ethz.seb.sebserver.gui.service.page.impl.PageAction; import ch.ethz.seb.sebserver.gui.service.remote.download.DownloadService; import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.RestCallError; import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.RestService; +import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.exam.ArchiveExam; import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.exam.CheckExamConsistency; import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.exam.CheckSEBRestriction; import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.exam.GetDefaultExamTemplate; @@ -118,6 +119,8 @@ public class ExamForm implements TemplateComposer { new LocTextKey("sebserver.exam.form.examTemplate"); private static final LocTextKey FORM_EXAM_TEMPLATE_ERROR = new LocTextKey("sebserver.exam.form.examTemplate.error"); + private static final LocTextKey EXAM_ARCHIVE_CONFIRM = + new LocTextKey("sebserver.exam.action.archive.confirm"); private final static LocTextKey CONSISTENCY_MESSAGE_TITLE = new LocTextKey("sebserver.exam.consistency.title"); @@ -412,6 +415,12 @@ public class ExamForm implements TemplateComposer { .withExec(this.examDeletePopup.deleteWizardFunction(pageContext)) .publishIf(() -> writeGrant && readonly) + .newAction(ActionDefinition.EXAM_ARCHIVE) + .withEntityKey(entityKey) + .withConfirm(() -> EXAM_ARCHIVE_CONFIRM) + .withExec(this::archiveExam) + .publishIf(() -> writeGrant && readonly && examStatus == ExamStatus.FINISHED) + .newAction(ActionDefinition.EXAM_SAVE) .withExec(action -> (importFromQuizData) ? importExam(action, formHandle, sebRestrictionAvailable && exam.status == ExamStatus.RUNNING) @@ -485,6 +494,16 @@ public class ExamForm implements TemplateComposer { } } + private PageAction archiveExam(final PageAction action) { + + this.restService.getBuilder(ArchiveExam.class) + .withURIVariable(API.PARAM_MODEL_ID, action.getEntityKey().modelId) + .call() + .onError(error -> action.pageContext().notifyUnexpectedError(error)); + + return action; + } + private String getDefaultExamTemplateId() { return this.restService.getBuilder(GetDefaultExamTemplate.class) .call() diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/exam/ExamList.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/exam/ExamList.java index 4a82aa48..11fb823f 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/exam/ExamList.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/exam/ExamList.java @@ -77,6 +77,8 @@ public class ExamList implements TemplateComposer { new LocTextKey("sebserver.exam.list.column.lmssetup"); public final static LocTextKey COLUMN_TITLE_NAME_KEY = new LocTextKey("sebserver.exam.list.column.name"); + public final static LocTextKey COLUMN_TITLE_STATE_KEY = + new LocTextKey("sebserver.exam.list.column.state"); public final static LocTextKey COLUMN_TITLE_TYPE_KEY = new LocTextKey("sebserver.exam.list.column.type"); public final static LocTextKey NO_MODIFY_OF_OUT_DATED_EXAMS = @@ -88,6 +90,7 @@ public class ExamList implements TemplateComposer { private final TableFilterAttribute lmsFilter; private final TableFilterAttribute nameFilter = new TableFilterAttribute(CriteriaType.TEXT, QuizData.FILTER_ATTR_NAME); + private final TableFilterAttribute stateFilter; private final TableFilterAttribute typeFilter; private final PageService pageService; @@ -112,6 +115,11 @@ public class ExamList implements TemplateComposer { LmsSetup.FILTER_ATTR_LMS_SETUP, this.resourceService::lmsSetupResource); + this.stateFilter = new TableFilterAttribute( + CriteriaType.SINGLE_SELECTION, + Exam.FILTER_ATTR_STATUS, + this.resourceService::localizedExamStatusSelection); + this.typeFilter = new TableFilterAttribute( CriteriaType.SINGLE_SELECTION, Exam.FILTER_ATTR_TYPE, @@ -189,6 +197,13 @@ public class ExamList implements TemplateComposer { .toString())) .sortable()) + .withColumn(new ColumnDefinition<>( + Domain.EXAM.ATTR_STATUS, + COLUMN_TITLE_STATE_KEY, + this.resourceService::localizedExamStatusName) + .withFilter(this.stateFilter) + .sortable()) + .withColumn(new ColumnDefinition( Domain.EXAM.ATTR_TYPE, COLUMN_TITLE_TYPE_KEY, diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/monitoring/FinishedExamList.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/monitoring/FinishedExamList.java index 2575d680..976ecf3d 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/monitoring/FinishedExamList.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/monitoring/FinishedExamList.java @@ -48,6 +48,8 @@ public class FinishedExamList implements TemplateComposer { new LocTextKey("sebserver.finished.exam.info.pleaseSelect"); private final static LocTextKey COLUMN_TITLE_NAME_KEY = new LocTextKey("sebserver.finished.exam.list.column.name"); + private final static LocTextKey COLUMN_TITLE_STATE_KEY = + new LocTextKey("sebserver.finished.exam.list.column.state"); private final static LocTextKey COLUMN_TITLE_TYPE_KEY = new LocTextKey("sebserver.finished.exam.list.column.type"); private final static LocTextKey EMPTY_LIST_TEXT_KEY = @@ -56,6 +58,7 @@ public class FinishedExamList implements TemplateComposer { private final TableFilterAttribute nameFilter = new TableFilterAttribute(CriteriaType.TEXT, QuizData.FILTER_ATTR_NAME); private final TableFilterAttribute typeFilter; + private final TableFilterAttribute stateFilter; private final PageService pageService; private final ResourceService resourceService; @@ -73,6 +76,11 @@ public class FinishedExamList implements TemplateComposer { CriteriaType.SINGLE_SELECTION, Exam.FILTER_ATTR_TYPE, this.resourceService::examTypeResources); + + this.stateFilter = new TableFilterAttribute( + CriteriaType.SINGLE_SELECTION, + Exam.FILTER_ATTR_STATUS, + this.resourceService::localizedFinishedExamStatusSelection); } @Override @@ -105,6 +113,13 @@ public class FinishedExamList implements TemplateComposer { .withFilter(this.nameFilter) .sortable()) + .withColumn(new ColumnDefinition<>( + Domain.EXAM.ATTR_STATUS, + COLUMN_TITLE_STATE_KEY, + this.resourceService::localizedExamStatusName) + .withFilter(this.stateFilter) + .sortable()) + .withColumn(new ColumnDefinition( Domain.EXAM.ATTR_TYPE, COLUMN_TITLE_TYPE_KEY, diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/ResourceService.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/ResourceService.java index 59422e38..420b5c42 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/ResourceService.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/ResourceService.java @@ -118,6 +118,7 @@ public class ResourceService { public static final EnumSet CLIENT_EVENT_TYPE_EXCLUDE_MAP = EnumSet.of( EventType.UNKNOWN); + public static final String EXAM_STATUS_PREFIX = "sebserver.exam.status."; public static final String EXAMCONFIG_STATUS_PREFIX = "sebserver.examconfig.status."; public static final String EXAM_TYPE_PREFIX = "sebserver.exam.type."; public static final String USERACCOUNT_ROLE_PREFIX = "sebserver.useraccount.role."; @@ -548,6 +549,32 @@ public class ResourceService { .apply(String.valueOf(config.institutionId)); } + public List> localizedExamStatusSelection() { + return Arrays.stream(ExamStatus.values()) + .map(type -> new Tuple<>(type.name(), + this.i18nSupport.getText(EXAM_STATUS_PREFIX + type.name()))) + .sorted(RESOURCE_COMPARATOR) + .collect(Collectors.toList()); + } + + public List> localizedFinishedExamStatusSelection() { + return Arrays.stream(ExamStatus.values()) + .filter(st -> st == ExamStatus.ARCHIVED || st == ExamStatus.FINISHED) + .map(type -> new Tuple<>(type.name(), + this.i18nSupport.getText(EXAM_STATUS_PREFIX + type.name()))) + .sorted(RESOURCE_COMPARATOR) + .collect(Collectors.toList()); + } + + public String localizedExamStatusName(final Exam exam) { + if (exam.status == null) { + return Constants.EMPTY_NOTE; + } + + return this.i18nSupport + .getText(ResourceService.EXAM_STATUS_PREFIX + exam.status.name()); + } + public String localizedExamConfigStatusName(final ConfigurationNode config) { if (config.status == null) { return Constants.EMPTY_NOTE; diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/exam/ArchiveExam.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/exam/ArchiveExam.java new file mode 100644 index 00000000..1783cb63 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/exam/ArchiveExam.java @@ -0,0 +1,42 @@ +/* + * 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.gui.service.remote.webservice.api.exam; + +import org.springframework.context.annotation.Lazy; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.type.TypeReference; + +import ch.ethz.seb.sebserver.gbl.api.API; +import ch.ethz.seb.sebserver.gbl.api.EntityType; +import ch.ethz.seb.sebserver.gbl.model.exam.Exam; +import ch.ethz.seb.sebserver.gbl.profile.GuiProfile; +import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.RestCall; + +@Lazy +@Component +@GuiProfile +public class ArchiveExam extends RestCall { + + public ArchiveExam() { + super(new TypeKey<>( + CallType.SAVE, + EntityType.EXAM, + new TypeReference() { + }), + HttpMethod.PATCH, + MediaType.APPLICATION_JSON, + API.EXAM_ADMINISTRATION_ENDPOINT + + API.MODEL_ID_VAR_PATH_SEGMENT + + API.EXAM_ADMINISTRATION_ARCHIVE_PATH_SEGMENT); + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/widget/WidgetFactory.java b/src/main/java/ch/ethz/seb/sebserver/gui/widget/WidgetFactory.java index 8cab238f..7ee7f9f6 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/widget/WidgetFactory.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/widget/WidgetFactory.java @@ -122,6 +122,7 @@ public class WidgetFactory { SECURE("secure.png"), NEW("new.png"), DELETE("delete.png"), + ARCHIVE("archive.png"), SEARCH("lens.png"), UNDO("undo.png"), COLOR("color.png"), 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 2390b63c..1cc0b602 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 @@ -9,6 +9,7 @@ package ch.ethz.seb.sebserver.webservice.servicelayer.dao; import java.util.Collection; +import java.util.function.Predicate; import org.springframework.cache.annotation.CacheEvict; @@ -80,10 +81,13 @@ public interface ExamDAO extends ActivatableEntityDAO, BulkActionSup /** Use this to get identifiers of all exams in a specified state for a specified institution. * - * @param institutionId the institution identifier. May be null for all institutions - * @param status the ExamStatus + * @param filterMap FilterMap with other filter criteria + * @param status the list of ExamStatus * @return Result refer to collection of exam identifiers or to an error if happened */ - Result> getExamIdsForStatus(Long institutionId, ExamStatus status); + Result> getExamIdsForStatus( + final FilterMap filterMap, + final Predicate predicate, + final ExamStatus... status); /** Gets all for active and none archived exams within the system, independently form institution and LMSSetup. * 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 7b274a39..c5d83172 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 @@ -34,6 +34,8 @@ import org.springframework.transaction.annotation.Propagation; 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.APIMessageException; import ch.ethz.seb.sebserver.gbl.api.EntityType; import ch.ethz.seb.sebserver.gbl.model.EntityDependency; import ch.ethz.seb.sebserver.gbl.model.EntityKey; @@ -118,38 +120,42 @@ public class ExamDAOImpl implements ExamDAO { return Result.tryCatch(() -> { - final String name = filterMap.getQuizName(); - final DateTime from = filterMap.getExamFromTime(); - final Predicate quizDataFilter = exam -> { - if (StringUtils.isNotBlank(name)) { - if (!exam.name.contains(name)) { - return false; - } - } - - if (from != null && exam.startTime != null) { - // always show exams that has not ended yet - if (exam.endTime == null || exam.endTime.isAfter(from)) { - return true; - } - if (exam.startTime.isBefore(from)) { - return false; - } - } - - return true; - }; - + final Predicate examDataFilter = createPredicate(filterMap); return this.examRecordDAO - .allMatching(filterMap) + .allMatching(filterMap, null) .flatMap(this::toDomainModel) .getOrThrow() .stream() - .filter(quizDataFilter.and(predicate)) + .filter(examDataFilter.and(predicate)) .collect(Collectors.toList()); }); } + private Predicate createPredicate(final FilterMap filterMap) { + final String name = filterMap.getQuizName(); + final DateTime from = filterMap.getExamFromTime(); + final Predicate quizDataFilter = exam -> { + if (StringUtils.isNotBlank(name)) { + if (!exam.name.contains(name)) { + return false; + } + } + + if (from != null && exam.startTime != null) { + // always show exams that has not ended yet + if (exam.endTime == null || exam.endTime.isAfter(from)) { + return true; + } + if (exam.startTime.isBefore(from)) { + return false; + } + } + + return true; + }; + return quizDataFilter; + } + @Override public Result updateState(final Long examId, final ExamStatus status, final String updateId) { return this.examRecordDAO @@ -159,9 +165,9 @@ public class ExamDAOImpl implements ExamDAO { @Override public Result save(final Exam exam) { - return this.examRecordDAO - .save(exam) - .map(rec -> saveAdditionalAttributes(exam, rec)) + return this.checkStateEdit(exam) + .flatMap(this.examRecordDAO::save) + .flatMap(rec -> saveAdditionalAttributes(exam, rec)) .flatMap(this::toDomainModel); } @@ -201,7 +207,7 @@ public class ExamDAOImpl implements ExamDAO { public Result createNew(final Exam exam) { return this.examRecordDAO .createNew(exam) - .map(rec -> saveAdditionalAttributes(exam, rec)) + .flatMap(rec -> saveAdditionalAttributes(exam, rec)) .flatMap(this::toDomainModel); } @@ -248,22 +254,26 @@ public class ExamDAOImpl implements ExamDAO { @Override @Transactional(readOnly = true) - public Result> getExamIdsForStatus(final Long institutionId, final ExamStatus status) { - return Result.tryCatch(() -> this.examRecordMapper.selectIdsByExample() - .where( - ExamRecordDynamicSqlSupport.active, - isEqualTo(BooleanUtils.toInteger(true))) - .and( - ExamRecordDynamicSqlSupport.institutionId, - isEqualToWhenPresent(institutionId)) - .and( - ExamRecordDynamicSqlSupport.status, - isEqualTo(status.name())) - .and( - ExamRecordDynamicSqlSupport.updating, - isEqualTo(BooleanUtils.toInteger(false))) - .build() - .execute()); + public Result> getExamIdsForStatus( + final FilterMap filterMap, + final Predicate predicate, + final ExamStatus... status) { + + return Result.tryCatch(() -> { + + final List stateNames = (status != null && status.length > 0) + ? Arrays.asList(status) + .stream().map(s -> s.name()) + .collect(Collectors.toList()) + : null; + final Predicate examDataFilter = createPredicate(filterMap); + return this.examRecordDAO.allMatching(filterMap, stateNames) + .flatMap(this::toDomainModel) + .getOrThrow() + .stream() + .filter(examDataFilter.and(predicate)) + .collect(Collectors.toList()); + }); } @Override @@ -697,81 +707,6 @@ public class ExamDAOImpl implements ExamDAO { }); } -// private QuizData getQuizData( -// final Map quizzes, -// final String externalId, -// final ExamRecord record) { -// -// if (quizzes.containsKey(externalId)) { -// return quizzes.get(externalId); -// } else { -// // If this is a Moodle quiz, try to recover from eventually restore of the quiz on the LMS side -// // NOTE: This is a workaround for Moodle quizzes that had have a recovery within the sandbox tool -// // Where potentially quiz identifiers get changed during such a recovery and the SEB Server -// // internal mapping is not working properly anymore. In this case we try to recover from such -// // a case by using the short name of the quiz and search for the quiz within the course with this -// // short name. If one quiz has been found that matches all criteria, we adapt the internal id -// // mapping to this quiz. -// // If recovering fails, this returns null and the calling side must handle the lack of quiz data -// try { -// final LmsSetup lmsSetup = this.lmsAPIService -// .getLmsSetup(record.getLmsSetupId()) -// .getOrThrow(); -// if (lmsSetup.lmsType == LmsType.MOODLE) { -// -// log.info("Try to recover quiz data for Moodle quiz with internal identifier: {}", externalId); -// -// // get former quiz name attribute -// final String formerName = getFormerName(record.getId()); -// if (formerName != null) { -// -// log.debug("Found formerName quiz name: {}", formerName); -// -// // get the course name identifier -// final String shortname = MoodleCourseAccess.getShortname(externalId); -// if (StringUtils.isNotBlank(shortname)) { -// -// log.debug("Using short-name: {} for recovering", shortname); -// -// final QuizData recoveredQuizData = this.lmsAPIService -// .getLmsAPITemplate(lmsSetup.id) -// .map(template -> template.getQuizzes(new FilterMap()) -// .getOrThrow() -// .stream() -// .filter(quiz -> { -// final String qShortName = MoodleCourseAccess.getShortname(quiz.id); -// return qShortName != null && qShortName.equals(shortname); -// }) -// .filter(quiz -> formerName.equals(quiz.name)) -// .findAny() -// .get()) -// .getOrThrow(); -// if (recoveredQuizData != null) { -// -// log.debug("Found quiz data for recovering: {}", recoveredQuizData); -// -// // save exam with new external id -// this.examRecordMapper.updateByPrimaryKeySelective(new ExamRecord( -// record.getId(), -// null, null, -// recoveredQuizData.id, -// null, null, null, null, null, null, null, null, null, null, null, -// Utils.getMillisecondsNow())); -// -// log.debug("Successfully recovered exam quiz data to new externalId {}", -// recoveredQuizData.id); -// } -// return recoveredQuizData; -// } -// } -// } -// } catch (final Exception e) { -// log.debug("Failed to try to recover from Moodle quiz restore: {}", e.getMessage()); -// } -// return null; -// } -// } - private Result toDomainModel(final ExamRecord record) { return Result.tryCatch(() -> { @@ -817,16 +752,19 @@ public class ExamDAOImpl implements ExamDAO { }); } - private ExamRecord saveAdditionalAttributes(final Exam exam, final ExamRecord rec) { - if (exam.additionalAttributesIncluded()) { - this.additionalAttributesDAO.saveAdditionalAttributes( - EntityType.EXAM, - rec.getId(), - exam.additionalAttributes) - .getOrThrow(); - } + private Result saveAdditionalAttributes(final Exam exam, final ExamRecord rec) { + return Result.tryCatch(() -> { + if (exam.additionalAttributesIncluded()) { + this.additionalAttributesDAO.saveAdditionalAttributes( + EntityType.EXAM, + rec.getId(), + exam.additionalAttributes) + .getOrThrow(); + } - return rec; + return rec; + + }); } private QuizData saveAdditionalQuizAttributes(final Long examId, final QuizData quizData) { @@ -843,4 +781,14 @@ public class ExamDAOImpl implements ExamDAO { return quizData; } + private Result checkStateEdit(final Exam exam) { + return Result.tryCatch(() -> { + + if (exam.status == ExamStatus.ARCHIVED) { + throw new APIMessageException(APIMessage.ErrorMessage.INTEGRITY_VALIDATION.of("Exam is archived")); + } + + 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 691729ef..1bf5f3ef 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 @@ -22,6 +22,8 @@ import org.apache.commons.lang3.StringUtils; import org.mybatis.dynamic.sql.SqlBuilder; import org.mybatis.dynamic.sql.select.MyBatis3SelectModelAdapter; import org.mybatis.dynamic.sql.select.QueryExpressionDSL; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -52,6 +54,8 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.dao.TransactionHandler; @WebServiceProfile public class ExamRecordDAO { + private static final Logger log = LoggerFactory.getLogger(ExamRecordDAO.class); + private final ExamRecordMapper examRecordMapper; private final ClientConnectionRecordMapper clientConnectionRecordMapper; @@ -133,13 +137,13 @@ public class ExamRecordDAO { } @Transactional(readOnly = true) - public Result> allMatching(final FilterMap filterMap) { + public Result> allMatching(final FilterMap filterMap, final List stateNames) { return Result.tryCatch(() -> { // If we have a sort on institution name, join the institution table // If we have a sort on lms setup name, join lms setup table - final QueryExpressionDSL>>.QueryExpressionWhereBuilder whereClause = + QueryExpressionDSL>>.QueryExpressionWhereBuilder whereClause = (filterMap.getBoolean(FilterMap.ATTR_ADD_INSITUTION_JOIN)) ? this.examRecordMapper .selectByExample() @@ -165,7 +169,9 @@ public class ExamRecordDAO { ExamRecordDynamicSqlSupport.active, isEqualToWhenPresent(filterMap.getActiveAsInt())); - final List records = whereClause + // + + whereClause = whereClause .and( ExamRecordDynamicSqlSupport.institutionId, isEqualToWhenPresent(filterMap.getInstitutionId())) @@ -174,10 +180,23 @@ public class ExamRecordDAO { isEqualToWhenPresent(filterMap.getLmsSetupId())) .and( ExamRecordDynamicSqlSupport.type, - isEqualToWhenPresent(filterMap.getExamType())) - .and( - ExamRecordDynamicSqlSupport.status, - isEqualToWhenPresent(filterMap.getExamStatus())) + isEqualToWhenPresent(filterMap.getExamType())); + + final String examStatus = filterMap.getExamStatus(); + if (StringUtils.isNotBlank(examStatus)) { + whereClause = whereClause + .and( + ExamRecordDynamicSqlSupport.status, + isEqualToWhenPresent(examStatus)); + } else if (stateNames != null && !stateNames.isEmpty()) { + whereClause = whereClause + .and( + ExamRecordDynamicSqlSupport.status, + isInWhenPresent(stateNames)); + } + + final List records = whereClause + .and( ExamRecordDynamicSqlSupport.quizName, isLikeWhenPresent(filterMap.getSQLWildcard(EXAM.ATTR_QUIZ_NAME))) @@ -192,7 +211,9 @@ public class ExamRecordDAO { public Result updateState(final Long examId, final ExamStatus status, final String updateId) { return recordById(examId) .map(examRecord -> { - if (BooleanUtils.isTrue(BooleanUtils.toBooleanObject(examRecord.getUpdating()))) { + if (updateId != null && + BooleanUtils.isTrue(BooleanUtils.toBooleanObject(examRecord.getUpdating()))) { + if (!updateId.equals(examRecord.getLastupdate())) { throw new IllegalStateException("Exam is currently locked: " + examRecord.getExternalId()); } @@ -221,6 +242,13 @@ public class ExamRecordDAO { throw new IllegalStateException("Exam is currently locked: " + exam.externalId); } + if (exam.status != null && !exam.status.name().equals(oldRecord.getStatus())) { + log.warn("Exam state change on save. Exam. {}, Old state: {}, new state: {}", + exam.externalId, + oldRecord.getStatus(), + exam.status); + } + final ExamRecord examRecord = new ExamRecord( exam.id, null, null, null, null, @@ -232,9 +260,7 @@ public class ExamRecordDAO { : null, null, exam.browserExamKeys, - (exam.status != null) - ? exam.status.name() - : null, + null, 1, // seb restriction (deprecated) null, // updating null, // lastUpdate @@ -412,6 +438,9 @@ public class ExamRecordDAO { .and( ExamRecordDynamicSqlSupport.status, isNotEqualTo(ExamStatus.RUNNING.name())) + .and( + ExamRecordDynamicSqlSupport.status, + isNotEqualTo(ExamStatus.ARCHIVED.name())) .and( ExamRecordDynamicSqlSupport.updating, isEqualTo(BooleanUtils.toInteger(false))) @@ -430,6 +459,9 @@ public class ExamRecordDAO { .and( ExamRecordDynamicSqlSupport.status, isEqualTo(ExamStatus.RUNNING.name())) + .and( + ExamRecordDynamicSqlSupport.status, + isNotEqualTo(ExamStatus.ARCHIVED.name())) .and( ExamRecordDynamicSqlSupport.updating, isEqualTo(BooleanUtils.toInteger(false))) 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 88950bb8..cad4963e 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 @@ -266,8 +266,11 @@ public class ExamSessionServiceImpl implements ExamSessionService { final FilterMap filterMap, final Predicate predicate) { - filterMap.putIfAbsent(Exam.FILTER_ATTR_STATUS, ExamStatus.FINISHED.name()); - return this.examDAO.allMatching(filterMap, predicate); + return this.examDAO.getExamIdsForStatus( + filterMap, + predicate, + ExamStatus.FINISHED, + ExamStatus.ARCHIVED); } @Override 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 ab4ddcea..7ec51587 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 @@ -149,7 +149,11 @@ public class SEBClientConnectionServiceImpl implements SEBClientConnectionServic } if (examId != null) { - checkExamIntegrity(examId, institutionId); + checkExamIntegrity( + examId, + institutionId, + (principal != null) ? principal.getName() : "--", + clientAddress); } // Create ClientConnection in status CONNECTION_REQUESTED for further processing @@ -233,7 +237,11 @@ public class SEBClientConnectionServiceImpl implements SEBClientConnectionServic } if (examId != null) { - checkExamIntegrity(examId, institutionId); + checkExamIntegrity( + examId, + institutionId, + StringUtils.isNoneBlank(userSessionId) ? userSessionId : clientConnection.userSessionId, + clientConnection.clientAddress); } if (log.isDebugEnabled()) { @@ -419,7 +427,11 @@ public class SEBClientConnectionServiceImpl implements SEBClientConnectionServic .getOrThrow(); // check exam integrity for established connection - checkExamIntegrity(establishedClientConnection.examId, institutionId); + checkExamIntegrity( + establishedClientConnection.examId, + institutionId, + establishedClientConnection.userSessionId, + establishedClientConnection.clientAddress); // initialize distributed indicator value caches if possible and needed if (examId != null && this.isDistributedSetup) { @@ -733,9 +745,9 @@ public class SEBClientConnectionServiceImpl implements SEBClientConnectionServic this.clientIndicatorFactory.getIndicatorValues(clientConnection))); } - private void checkExamRunning(final Long examId) { + private void checkExamRunning(final Long examId, final String user, final String address) { if (examId != null && !this.examSessionService.isExamRunning(examId)) { - examNotRunningException(examId); + examNotRunningException(examId, user, address); } } @@ -761,9 +773,10 @@ public class SEBClientConnectionServiceImpl implements SEBClientConnectionServic return UUID.randomUUID().toString(); } - private void examNotRunningException(final Long examId) { - log.error("The exam {} is not running", examId); - throw new IllegalStateException("The exam " + examId + " is not running"); + private void examNotRunningException(final Long examId, final String user, final String address) { + log.warn("The exam {} is not running. Called by: {} | on: {}", examId, user, address); + throw new APIConstraintViolationException( + "The exam " + examId + " is not running"); } private void checkExamIntegrity(final Long examId, final ClientConnection clientConnection) { @@ -776,7 +789,7 @@ public class SEBClientConnectionServiceImpl implements SEBClientConnectionServic throw new IllegalArgumentException( "Exam integrity violation: another examId is already set for the connection"); } - checkExamRunning(examId); + checkExamRunning(examId, clientConnection.userSessionId, clientConnection.clientAddress); } private ClientConnection updateUserSessionId( @@ -835,7 +848,12 @@ public class SEBClientConnectionServiceImpl implements SEBClientConnectionServic return clientConnection; } - private void checkExamIntegrity(final Long examId, final Long institutionId) { + private void checkExamIntegrity( + final Long examId, + final Long institutionId, + final String user, + final String address) { + if (this.isDistributedSetup) { // if the cached Exam is not up to date anymore, we have to update the cache first final Result updateExamCache = this.examSessionService.updateExamCache(examId); @@ -845,7 +863,7 @@ public class SEBClientConnectionServiceImpl implements SEBClientConnectionServic } // check Exam is running and not locked - checkExamRunning(examId); + checkExamRunning(examId, user, address); if (this.examSessionService.isExamLocked(examId)) { throw new APIConstraintViolationException( "Exam is currently on update and locked for new SEB Client connections"); diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/APIExceptionHandler.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/APIExceptionHandler.java index f3fec7b1..93f9ab1c 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/APIExceptionHandler.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/APIExceptionHandler.java @@ -192,7 +192,8 @@ public class APIExceptionHandler extends ResponseEntityExceptionHandler { final APIConstraintViolationException ex, final WebRequest request) { - log.warn("Illegal API Argument Exception: ", ex); + log.warn("Illegal API Argument Exception: {}", ex.getMessage()); + return APIMessage.ErrorMessage.ILLEGAL_API_ARGUMENT .createErrorResponse(ex.getMessage()); } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamAdministrationController.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamAdministrationController.java index b81e243b..88731f0a 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamAdministrationController.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamAdministrationController.java @@ -47,6 +47,7 @@ import ch.ethz.seb.sebserver.gbl.model.Page; import ch.ethz.seb.sebserver.gbl.model.PageSortOrder; import ch.ethz.seb.sebserver.gbl.model.exam.Chapters; import ch.ethz.seb.sebserver.gbl.model.exam.Exam; +import ch.ethz.seb.sebserver.gbl.model.exam.Exam.ExamStatus; import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings; import ch.ethz.seb.sebserver.gbl.model.exam.QuizData; import ch.ethz.seb.sebserver.gbl.model.exam.SEBRestriction; @@ -213,6 +214,27 @@ public class ExamAdministrationController extends EntityController { return result; } + @RequestMapping( + path = API.MODEL_ID_VAR_PATH_SEGMENT + + API.EXAM_ADMINISTRATION_ARCHIVE_PATH_SEGMENT, + method = RequestMethod.PATCH, + produces = MediaType.APPLICATION_JSON_VALUE) + public Exam archive( + @PathVariable final Long modelId, + @RequestParam( + name = API.PARAM_INSTITUTION_ID, + required = true, + defaultValue = UserService.USERS_INSTITUTION_AS_DEFAULT) final Long institutionId) { + + checkWritePrivilege(institutionId); + return this.examDAO.byPK(modelId) + .flatMap(this::checkWriteAccess) + .flatMap(this::checkArchive) + .flatMap(exam -> this.examDAO.updateState(exam.id, ExamStatus.ARCHIVED, null)) + .flatMap(this::logModify) + .getOrThrow(); + } + // **************************************************************************** // **** SEB Restriction @@ -557,6 +579,15 @@ public class ExamAdministrationController extends EntityController { }); } + private Result checkArchive(final Exam exam) { + if (exam.status != ExamStatus.FINISHED) { + throw new APIMessageException( + APIMessage.ErrorMessage.INTEGRITY_VALIDATION.of("Exam is in wrong status to archive.")); + } + + return Result.of(exam); + } + static Function, List> pageSort(final String sort) { final String sortBy = PageSortOrder.decode(sort); diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index f99ded4b..b9d3b453 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -466,6 +466,8 @@ sebserver.exam.list.column.starttime=Start Time {0} sebserver.exam.list.column.starttime.tooltip=The start time of the exam

Use the filter above to set a specific from date
{0} sebserver.exam.list.column.type=Type sebserver.exam.list.column.type.tooltip=The type of the exam

Use the filter above to set a specific exam type
{0} +sebserver.exam.list.column.state=Status +sebserver.exam.list.column.state.tooltip=The current status of the exam

Use the filter above to set a specific exam status
{0} sebserver.exam.list.empty=No Exam can be found. Please adapt the filter or import one from LMS sebserver.exam.list.modify.out.dated=Finished exams cannot be modified. @@ -492,6 +494,8 @@ sebserver.exam.action.activate=Activate Exam sebserver.exam.action.deactivate=Deactivate Exam sebserver.exam.action.delete=Delete Exam sebserver.exam.action.delete.consistency.error=Deletion Failed.
Please make sure there are no active SEB clients connected to the exam before deletion. +sebserver.exam.action.archive=Archive +sebserver.exam.action.archive.confirm=An archived exam cannot be rerun and will remain in read-only view.

Are you sure to archive the exam? sebserver.exam.action.sebrestriction.enable=Apply SEB Lock sebserver.exam.action.sebrestriction.disable=Release SEB Lock sebserver.exam.action.sebrestriction.details=SEB Restriction Details @@ -584,6 +588,7 @@ sebserver.exam.type.VDI.tooltip=Exam type specified for Virtual Desktop Infrastr sebserver.exam.status.UP_COMING=Up Coming sebserver.exam.status.RUNNING=Running sebserver.exam.status.FINISHED=Finished +sebserver.exam.status.ARCHIVED=Archived sebserver.exam.status.CORRUPT_NO_LMS_CONNECTION=Corrupt (No LMS Connection) sebserver.exam.status.CORRUPT_INVALID_ID=Corrupt (Invalid Identifier) @@ -1937,6 +1942,8 @@ sebserver.finished.exam.list.empty=There are currently no finished exams sebserver.finished.exam.list.column.name=Name sebserver.finished.exam.list.column.name.tooltip=The name of the exam

Use the filter above to narrow down to a specific exam name
{0} +sebserver.finished.exam.list.column.state=State +sebserver.finished.exam.list.column.state.tooltip=The state of the finished exam

Use the filter above to see only archived or finished exams sebserver.finished.exam.list.column.type=Type sebserver.finished.exam.list.column.type.tooltip=The type of the exam

Use the filter above to set a specific exam type
{0} sebserver.finished.exam.list.column.startTime=Start Time {0} diff --git a/src/main/resources/static/images/archive.png b/src/main/resources/static/images/archive.png new file mode 100644 index 0000000000000000000000000000000000000000..bcf4507808997d24cd21e99af63d5a2ff848ef64 GIT binary patch literal 139 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_+iAWs*^5R22v2@-k-<~}q$T(1)0 z?{T&+dhc!ggsN6nqcYx4AFL`r*&YyMv*vt^P@t!Y$8~>;q3lPMHckSMV~i mQ8LY}SfHBU)R~{b%ph|3uXfMG59@)ZGkCiCxvX>() { }); final APIMessage error = errorMessage.iterator().next(); - assertEquals(ErrorMessage.UNEXPECTED.messageCode, error.messageCode); + assertEquals(ErrorMessage.ILLEGAL_API_ARGUMENT.messageCode, error.messageCode); assertEquals("The exam 1 is not running", error.details); }