SEBSERV-308 added archived state

This commit is contained in:
anhefti 2022-05-23 14:42:20 +02:00
parent c342dcdbdd
commit 960864e58f
19 changed files with 327 additions and 159 deletions

View file

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

View file

@ -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"),

View file

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

View file

@ -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<Exam>(
Domain.EXAM.ATTR_TYPE,
COLUMN_TITLE_TYPE_KEY,

View file

@ -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<Exam>(
Domain.EXAM.ATTR_TYPE,
COLUMN_TITLE_TYPE_KEY,

View file

@ -118,6 +118,7 @@ public class ResourceService {
public static final EnumSet<EventType> 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<Tuple<String>> 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<Tuple<String>> 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;

View file

@ -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<Exam> {
public ArchiveExam() {
super(new TypeKey<>(
CallType.SAVE,
EntityType.EXAM,
new TypeReference<Exam>() {
}),
HttpMethod.PATCH,
MediaType.APPLICATION_JSON,
API.EXAM_ADMINISTRATION_ENDPOINT
+ API.MODEL_ID_VAR_PATH_SEGMENT
+ API.EXAM_ADMINISTRATION_ARCHIVE_PATH_SEGMENT);
}
}

View file

@ -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"),

View file

@ -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<Exam, Exam>, 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<Collection<Long>> getExamIdsForStatus(Long institutionId, ExamStatus status);
Result<Collection<Exam>> getExamIdsForStatus(
final FilterMap filterMap,
final Predicate<Exam> predicate,
final ExamStatus... status);
/** Gets all for active and none archived exams within the system, independently form institution and LMSSetup.
*

View file

@ -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<Exam> 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<Exam> 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<Exam> createPredicate(final FilterMap filterMap) {
final String name = filterMap.getQuizName();
final DateTime from = filterMap.getExamFromTime();
final Predicate<Exam> 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<Exam> 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<Exam> 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<Exam> 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<Collection<Long>> 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<Collection<Exam>> getExamIdsForStatus(
final FilterMap filterMap,
final Predicate<Exam> predicate,
final ExamStatus... status) {
return Result.tryCatch(() -> {
final List<String> stateNames = (status != null && status.length > 0)
? Arrays.asList(status)
.stream().map(s -> s.name())
.collect(Collectors.toList())
: null;
final Predicate<Exam> 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<String, QuizData> 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<Exam> 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<ExamRecord> 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<Exam> 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;
});
}
}

View file

@ -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<Collection<ExamRecord>> allMatching(final FilterMap filterMap) {
public Result<Collection<ExamRecord>> allMatching(final FilterMap filterMap, final List<String> 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<MyBatis3SelectModelAdapter<List<ExamRecord>>>.QueryExpressionWhereBuilder whereClause =
QueryExpressionDSL<MyBatis3SelectModelAdapter<List<ExamRecord>>>.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<ExamRecord> 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<ExamRecord> records = whereClause
.and(
ExamRecordDynamicSqlSupport.quizName,
isLikeWhenPresent(filterMap.getSQLWildcard(EXAM.ATTR_QUIZ_NAME)))
@ -192,7 +211,9 @@ public class ExamRecordDAO {
public Result<ExamRecord> 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)))

View file

@ -266,8 +266,11 @@ public class ExamSessionServiceImpl implements ExamSessionService {
final FilterMap filterMap,
final Predicate<Exam> 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

View file

@ -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<Exam> 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");

View file

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

View file

@ -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<Exam, Exam> {
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<Exam, Exam> {
});
}
private Result<Exam> 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<Collection<Exam>, List<Exam>> pageSort(final String sort) {
final String sortBy = PageSortOrder.decode(sort);

View file

@ -466,6 +466,8 @@ sebserver.exam.list.column.starttime=Start Time {0}
sebserver.exam.list.column.starttime.tooltip=The start time of the exam<br/><br/>Use the filter above to set a specific from date<br/>{0}
sebserver.exam.list.column.type=Type
sebserver.exam.list.column.type.tooltip=The type of the exam<br/><br/>Use the filter above to set a specific exam type<br/>{0}
sebserver.exam.list.column.state=Status
sebserver.exam.list.column.state.tooltip=The current status of the exam<br/><br/>Use the filter above to set a specific exam status<br/>{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.<br/>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.<br/><br/>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<br/><br/>Use the filter above to narrow down to a specific exam name<br/>{0}
sebserver.finished.exam.list.column.state=State
sebserver.finished.exam.list.column.state.tooltip=The state of the finished exam<br/><br/>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<br/><br/>Use the filter above to set a specific exam type<br/>{0}
sebserver.finished.exam.list.column.startTime=Start Time {0}

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 B

View file

@ -62,7 +62,6 @@ import ch.ethz.seb.sebserver.gbl.model.EntityProcessingReport.ErrorEntry;
import ch.ethz.seb.sebserver.gbl.model.Page;
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.Exam.ExamType;
import ch.ethz.seb.sebserver.gbl.model.exam.ExamConfigurationMap;
import ch.ethz.seb.sebserver.gbl.model.exam.ExamTemplate;
@ -891,7 +890,7 @@ public class UseCasesIntegrationTest extends GuiIntegrationTest {
ExamType.MANAGED,
null,
Utils.immutableCollectionOf(userId),
ExamStatus.RUNNING,
null,
false,
null,
true,

View file

@ -170,7 +170,7 @@ public class SebConnectionTest extends ExamAPIIntegrationTester {
new TypeReference<Collection<APIMessage>>() {
});
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);
}