SEBSERV-317 also archive exam config and release SEB restriction
This commit is contained in:
parent
22c0dd872d
commit
b44c5f4eb2
12 changed files with 162 additions and 38 deletions
|
@ -87,7 +87,7 @@ public final class CircuitBreaker<T> {
|
|||
/** Create new CircuitBreakerSupplier.
|
||||
*
|
||||
* @param asyncRunner the AsyncRunner used to create asynchronous calls on the given supplier function
|
||||
* @param maxFailingAttempts the number of maximal failing attempts before go form CLOSE into HALF_OPEN state
|
||||
* @param maxFailingAttempts the number of maximal failing attempts before go from CLOSE into HALF_OPEN state
|
||||
* @param maxBlockingTime the maximal time that an call attempt can block until an error is responded
|
||||
* @param timeToRecover the time the circuit breaker needs to cool-down on OPEN-STATE before going back to HALF_OPEN
|
||||
* state */
|
||||
|
@ -169,7 +169,7 @@ public final class CircuitBreaker<T> {
|
|||
|
||||
final long currentBlockingTime = Utils.getMillisecondsNow() - startTime;
|
||||
final int failing = this.failingCount.incrementAndGet();
|
||||
if (failing > this.maxFailingAttempts || currentBlockingTime > this.maxBlockingTime) {
|
||||
if (failing >= this.maxFailingAttempts || currentBlockingTime > this.maxBlockingTime) {
|
||||
// brake thought to HALF_OPEN state and return error
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Changing state from Open to Half Open");
|
||||
|
|
|
@ -445,21 +445,21 @@ public class ExamForm implements TemplateComposer {
|
|||
.withEntityKey(entityKey)
|
||||
.withExec(this.examSEBRestrictionSettings.settingsFunction(this.pageService))
|
||||
.withAttribute(ExamSEBRestrictionSettings.PAGE_CONTEXT_ATTR_LMS_ID, String.valueOf(exam.lmsSetupId))
|
||||
.withAttribute(PageContext.AttributeKeys.FORCE_READ_ONLY, String.valueOf(!modifyGrant))
|
||||
.withAttribute(PageContext.AttributeKeys.FORCE_READ_ONLY, String.valueOf(!modifyGrant || !editable))
|
||||
.noEventPropagation()
|
||||
.publishIf(() -> sebRestrictionAvailable && readonly)
|
||||
|
||||
.newAction(ActionDefinition.EXAM_ENABLE_SEB_RESTRICTION)
|
||||
.withEntityKey(entityKey)
|
||||
.withExec(action -> this.examSEBRestrictionSettings.setSEBRestriction(action, true, this.restService))
|
||||
.publishIf(() -> sebRestrictionAvailable && readonly && editable && !importFromQuizData
|
||||
.publishIf(() -> sebRestrictionAvailable && readonly && modifyGrant && !importFromQuizData
|
||||
&& BooleanUtils.isFalse(isRestricted))
|
||||
|
||||
.newAction(ActionDefinition.EXAM_DISABLE_SEB_RESTRICTION)
|
||||
.withConfirm(() -> ACTION_MESSAGE_SEB_RESTRICTION_RELEASE)
|
||||
.withEntityKey(entityKey)
|
||||
.withExec(action -> this.examSEBRestrictionSettings.setSEBRestriction(action, false, this.restService))
|
||||
.publishIf(() -> sebRestrictionAvailable && readonly && editable && !importFromQuizData
|
||||
.publishIf(() -> sebRestrictionAvailable && readonly && modifyGrant && !importFromQuizData
|
||||
&& BooleanUtils.isTrue(isRestricted))
|
||||
|
||||
.newAction(ActionDefinition.EXAM_PROCTORING_ON)
|
||||
|
|
|
@ -11,6 +11,7 @@ package ch.ethz.seb.sebserver.webservice.servicelayer.dao.impl;
|
|||
import static org.mybatis.dynamic.sql.SqlBuilder.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
@ -64,6 +65,10 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.dao.TransactionHandler;
|
|||
@WebServiceProfile
|
||||
public class ExamConfigurationMapDAOImpl implements ExamConfigurationMapDAO {
|
||||
|
||||
private static final List<String> ACTIVE_EXAM_STATE_NAMES = Arrays.asList(
|
||||
ExamStatus.FINISHED.name(),
|
||||
ExamStatus.ARCHIVED.name());
|
||||
|
||||
private final ExamRecordMapper examRecordMapper;
|
||||
private final ExamConfigurationMapRecordMapper examConfigurationMapRecordMapper;
|
||||
private final ConfigurationNodeRecordMapper configurationNodeRecordMapper;
|
||||
|
@ -344,7 +349,8 @@ public class ExamConfigurationMapDAOImpl implements ExamConfigurationMapDAO {
|
|||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public Result<Collection<Long>> getExamIdsForConfigNodeId(final Long configurationNodeId) {
|
||||
return Result.tryCatch(() -> this.examConfigurationMapRecordMapper.selectByExample()
|
||||
return Result.tryCatch(() -> this.examConfigurationMapRecordMapper
|
||||
.selectByExample()
|
||||
.where(
|
||||
ExamConfigurationMapRecordDynamicSqlSupport.configurationNodeId,
|
||||
isEqualTo(configurationNodeId))
|
||||
|
@ -383,16 +389,16 @@ public class ExamConfigurationMapDAOImpl implements ExamConfigurationMapDAO {
|
|||
.build()
|
||||
.execute()
|
||||
.stream()
|
||||
.filter(rec -> !isExamFinished(rec.getExamId()))
|
||||
.filter(rec -> !isExamActive(rec.getExamId()))
|
||||
.findFirst()
|
||||
.isPresent());
|
||||
}
|
||||
|
||||
private boolean isExamFinished(final Long examId) {
|
||||
private boolean isExamActive(final Long examId) {
|
||||
try {
|
||||
return this.examRecordMapper.countByExample()
|
||||
.where(ExamRecordDynamicSqlSupport.id, isEqualTo(examId))
|
||||
.and(ExamRecordDynamicSqlSupport.status, isEqualTo(ExamStatus.FINISHED.name()))
|
||||
.and(ExamRecordDynamicSqlSupport.status, isIn(ACTIVE_EXAM_STATE_NAMES))
|
||||
.build()
|
||||
.execute() >= 1;
|
||||
} catch (final Exception e) {
|
||||
|
|
|
@ -97,6 +97,13 @@ public interface ExamAdminService {
|
|||
* @return ExamProctoringService instance */
|
||||
Result<ExamProctoringService> getExamProctoringService(final Long examId);
|
||||
|
||||
/** This archives a finished exam and set it to archived state as well as the assigned
|
||||
* exam configurations that are also set to archived state.
|
||||
*
|
||||
* @param exam The exam to archive
|
||||
* @return Result refer to the archived exam or to an error when happened */
|
||||
Result<Exam> archiveExam(Exam exam);
|
||||
|
||||
/** Used to check threshold consistency for a given list of thresholds.
|
||||
* Checks if all values are present (none null value)
|
||||
* Checks if there are duplicates
|
||||
|
|
|
@ -20,17 +20,24 @@ 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.APIMessageException;
|
||||
import ch.ethz.seb.sebserver.gbl.api.EntityType;
|
||||
import ch.ethz.seb.sebserver.gbl.model.EntityKey;
|
||||
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.OpenEdxSEBRestriction;
|
||||
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.institution.LmsSetup;
|
||||
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup.LmsType;
|
||||
import ch.ethz.seb.sebserver.gbl.model.sebconfig.ConfigurationNode;
|
||||
import ch.ethz.seb.sebserver.gbl.model.sebconfig.ConfigurationNode.ConfigurationStatus;
|
||||
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
|
||||
import ch.ethz.seb.sebserver.gbl.util.Result;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.AdditionalAttributesDAO;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ConfigurationNodeDAO;
|
||||
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.exam.ExamAdminService;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.exam.ProctoringAdminService;
|
||||
|
@ -49,17 +56,23 @@ public class ExamAdminServiceImpl implements ExamAdminService {
|
|||
private final ExamDAO examDAO;
|
||||
private final ProctoringAdminService proctoringServiceSettingsService;
|
||||
private final AdditionalAttributesDAO additionalAttributesDAO;
|
||||
private final ConfigurationNodeDAO configurationNodeDAO;
|
||||
private final ExamConfigurationMapDAO examConfigurationMapDAO;
|
||||
private final LmsAPIService lmsAPIService;
|
||||
|
||||
protected ExamAdminServiceImpl(
|
||||
final ExamDAO examDAO,
|
||||
final ProctoringAdminService proctoringServiceSettingsService,
|
||||
final AdditionalAttributesDAO additionalAttributesDAO,
|
||||
final ConfigurationNodeDAO configurationNodeDAO,
|
||||
final ExamConfigurationMapDAO examConfigurationMapDAO,
|
||||
final LmsAPIService lmsAPIService) {
|
||||
|
||||
this.examDAO = examDAO;
|
||||
this.proctoringServiceSettingsService = proctoringServiceSettingsService;
|
||||
this.additionalAttributesDAO = additionalAttributesDAO;
|
||||
this.configurationNodeDAO = configurationNodeDAO;
|
||||
this.examConfigurationMapDAO = examConfigurationMapDAO;
|
||||
this.lmsAPIService = lmsAPIService;
|
||||
}
|
||||
|
||||
|
@ -114,7 +127,8 @@ public class ExamAdminServiceImpl implements ExamAdminService {
|
|||
|
||||
return this.lmsAPIService
|
||||
.getLmsAPITemplate(exam.lmsSetupId)
|
||||
.map(lmsAPI -> !lmsAPI.hasSEBClientRestriction(exam));
|
||||
.map(lmsAPI -> lmsAPI.hasSEBClientRestriction(exam))
|
||||
.onError(error -> log.error("Failed to check SEB restriction: ", error));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -182,4 +196,55 @@ public class ExamAdminServiceImpl implements ExamAdminService {
|
|||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<Exam> archiveExam(final Exam exam) {
|
||||
return Result.tryCatch(() -> {
|
||||
|
||||
if (exam.status != ExamStatus.FINISHED) {
|
||||
throw new APIMessageException(
|
||||
APIMessage.ErrorMessage.INTEGRITY_VALIDATION.of("Exam is in wrong status to archive."));
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Archiving exam: {}", exam);
|
||||
}
|
||||
|
||||
if (this.isRestricted(exam).getOr(false)) {
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Archiving exam, SEB restriction still active, try to release: {}", exam);
|
||||
}
|
||||
|
||||
this.lmsAPIService
|
||||
.getLmsAPITemplate(exam.lmsSetupId)
|
||||
.flatMap(lms -> lms.releaseSEBClientRestriction(exam))
|
||||
.onError(error -> log.error(
|
||||
"Failed to release SEB client restriction for archiving exam: ",
|
||||
error));
|
||||
}
|
||||
|
||||
final Exam result = this.examDAO
|
||||
.updateState(exam.id, ExamStatus.ARCHIVED, null)
|
||||
.getOrThrow();
|
||||
|
||||
this.examConfigurationMapDAO
|
||||
.getConfigurationNodeIds(result.id)
|
||||
.getOrThrow()
|
||||
.stream()
|
||||
.forEach(configNodeId -> {
|
||||
if (this.examConfigurationMapDAO.checkNoActiveExamReferences(configNodeId).getOr(false)) {
|
||||
log.debug("Also set exam configuration to archived: ", configNodeId);
|
||||
this.configurationNodeDAO.save(
|
||||
new ConfigurationNode(
|
||||
configNodeId, null, null, null, null, null,
|
||||
null, ConfigurationStatus.ARCHIVED, null, null))
|
||||
.onError(error -> log.error("Failed to set exam configuration to archived state: ",
|
||||
error));
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@ public interface SEBRestrictionAPI {
|
|||
*
|
||||
* A SEB Restriction is available if there it can get from LMS and if there is either a Config-Key
|
||||
* or a BrowserExam-Key set or both. If none of this keys is set, the SEB Restriction is been
|
||||
* considdered to not set on the LMS.
|
||||
* considered to not set on the LMS.
|
||||
*
|
||||
* @param exam exam the exam to get the SEB restriction data for
|
||||
* @return true if there is a SEB restriction set on the LMS for the exam or false otherwise */
|
||||
|
|
|
@ -130,7 +130,7 @@ public class LmsAPITemplateAdapter implements LmsAPITemplate {
|
|||
environment.getProperty(
|
||||
"sebserver.webservice.circuitbreaker.chaptersRequest.attempts",
|
||||
Integer.class,
|
||||
3),
|
||||
1),
|
||||
environment.getProperty(
|
||||
"sebserver.webservice.circuitbreaker.chaptersRequest.blockingTime",
|
||||
Long.class,
|
||||
|
@ -158,7 +158,7 @@ public class LmsAPITemplateAdapter implements LmsAPITemplate {
|
|||
environment.getProperty(
|
||||
"sebserver.webservice.circuitbreaker.sebrestriction.attempts",
|
||||
Integer.class,
|
||||
2),
|
||||
1),
|
||||
environment.getProperty(
|
||||
"sebserver.webservice.circuitbreaker.sebrestriction.blockingTime",
|
||||
Long.class,
|
||||
|
@ -388,20 +388,24 @@ public class LmsAPITemplateAdapter implements LmsAPITemplate {
|
|||
|
||||
return this.restrictionRequest.protectedRun(() -> this.sebRestrictionAPI
|
||||
.getSEBClientRestriction(exam)
|
||||
.onError(error -> log.error(
|
||||
"Failed to get SEB restrictions: {}",
|
||||
error.getMessage()))
|
||||
.onError(error -> {
|
||||
if (error instanceof NoSEBRestrictionException) {
|
||||
return;
|
||||
}
|
||||
log.error("Failed to get SEB restrictions: {}", error.getMessage());
|
||||
})
|
||||
.getOrThrow());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasSEBClientRestriction(final Exam exam) {
|
||||
if (this.sebRestrictionAPI == null) {
|
||||
final Result<SEBRestriction> sebClientRestriction = getSEBClientRestriction(exam);
|
||||
if (sebClientRestriction.hasError()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.sebRestrictionAPI
|
||||
.getSEBClientRestriction(exam).hasError();
|
||||
final SEBRestriction sebRestriction = sebClientRestriction.get();
|
||||
return !sebRestriction.configKeys.isEmpty() || !sebRestriction.browserExamKeys.isEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -30,7 +30,6 @@ import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup.LmsType;
|
|||
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult;
|
||||
import ch.ethz.seb.sebserver.gbl.util.Result;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.SEBRestrictionAPI;
|
||||
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.NoSEBRestrictionException;
|
||||
|
||||
/** The open edX SEB course restriction API implementation.
|
||||
*
|
||||
|
@ -134,8 +133,9 @@ public class OpenEdxCourseRestriction implements SEBRestrictionAPI {
|
|||
}
|
||||
return SEBRestriction.from(exam.id, data);
|
||||
} catch (final HttpClientErrorException ce) {
|
||||
if (ce.getStatusCode() == HttpStatus.NOT_FOUND || ce.getStatusCode() == HttpStatus.UNAUTHORIZED) {
|
||||
throw new NoSEBRestrictionException(ce);
|
||||
if (ce.getStatusCode() == HttpStatus.NOT_FOUND) {
|
||||
// No SEB restriction is set for the specified exam, return an empty one
|
||||
return new SEBRestriction(exam.id, null, null, null);
|
||||
}
|
||||
throw ce;
|
||||
}
|
||||
|
|
|
@ -47,7 +47,6 @@ 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;
|
||||
|
@ -229,8 +228,9 @@ public class ExamAdministrationController extends EntityController<Exam, Exam> {
|
|||
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.examAdminService::archiveExam)
|
||||
// .flatMap(this::checkArchive)
|
||||
// .flatMap(exam -> this.examDAO.updateState(exam.id, ExamStatus.ARCHIVED, null))
|
||||
.flatMap(this::logModify)
|
||||
.getOrThrow();
|
||||
}
|
||||
|
@ -581,15 +581,6 @@ 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);
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
server.address=localhost
|
||||
server.port=8090
|
||||
server.port=8080
|
||||
|
||||
sebserver.gui.http.external.scheme=http
|
||||
sebserver.gui.entrypoint=/gui
|
||||
sebserver.gui.webservice.protocol=http
|
||||
sebserver.gui.webservice.address=localhost
|
||||
sebserver.gui.webservice.port=8090
|
||||
sebserver.gui.webservice.port=8080
|
||||
sebserver.gui.webservice.apipath=/admin-api/v1
|
||||
# defines the polling interval that is used to poll the webservice for client connection data on a monitored exam page
|
||||
sebserver.gui.webservice.poll-interval=1000
|
||||
|
|
|
@ -39,10 +39,61 @@ public class CircuitBreakerTest {
|
|||
assertNotNull(this.asyncService);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMaxAttempts1() {
|
||||
final CircuitBreaker<String> circuitBreaker =
|
||||
this.asyncService.createCircuitBreaker(1, 500, 1000);
|
||||
|
||||
final AtomicInteger attemptCounter = new AtomicInteger(0);
|
||||
final Supplier<String> tester = () -> {
|
||||
attemptCounter.getAndIncrement();
|
||||
throw new RuntimeException("Test Error");
|
||||
};
|
||||
|
||||
final Result<String> result = circuitBreaker.protectedRun(tester); // 1. call...
|
||||
assertTrue(result.hasError());
|
||||
assertEquals(State.HALF_OPEN, circuitBreaker.getState());
|
||||
assertEquals("1", String.valueOf(attemptCounter.get()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMaxAttempts4() {
|
||||
final CircuitBreaker<String> circuitBreaker =
|
||||
this.asyncService.createCircuitBreaker(4, 500, 1000);
|
||||
|
||||
final AtomicInteger attemptCounter = new AtomicInteger(0);
|
||||
final Supplier<String> tester = () -> {
|
||||
attemptCounter.getAndIncrement();
|
||||
throw new RuntimeException("Test Error");
|
||||
};
|
||||
|
||||
final Result<String> result = circuitBreaker.protectedRun(tester); // 1. call...
|
||||
assertTrue(result.hasError());
|
||||
assertEquals(State.HALF_OPEN, circuitBreaker.getState());
|
||||
assertEquals("4", String.valueOf(attemptCounter.get()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMaxAttempts0() {
|
||||
final CircuitBreaker<String> circuitBreaker =
|
||||
this.asyncService.createCircuitBreaker(0, 500, 1000);
|
||||
|
||||
final AtomicInteger attemptCounter = new AtomicInteger(0);
|
||||
final Supplier<String> tester = () -> {
|
||||
attemptCounter.getAndIncrement();
|
||||
throw new RuntimeException("Test Error");
|
||||
};
|
||||
|
||||
final Result<String> result = circuitBreaker.protectedRun(tester); // 1. call...
|
||||
assertTrue(result.hasError());
|
||||
assertEquals(State.HALF_OPEN, circuitBreaker.getState());
|
||||
assertEquals("1", String.valueOf(attemptCounter.get()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void roundtrip1() throws InterruptedException {
|
||||
final CircuitBreaker<String> circuitBreaker =
|
||||
this.asyncService.createCircuitBreaker(3, 500, 1000);
|
||||
this.asyncService.createCircuitBreaker(4, 500, 1000);
|
||||
|
||||
final Supplier<String> tester = tester(100, 5, 10);
|
||||
|
||||
|
|
|
@ -42,7 +42,7 @@ public class MemoizingCircuitBreakerTest {
|
|||
@Test
|
||||
public void roundtrip1() throws InterruptedException {
|
||||
final MemoizingCircuitBreaker<String> circuitBreaker = this.asyncService.createMemoizingCircuitBreaker(
|
||||
tester(100, 5, 10), 3, 500, 1000, true, 1000);
|
||||
tester(100, 5, 10), 4, 500, 1000, true, 1000);
|
||||
|
||||
assertNull(circuitBreaker.getCached());
|
||||
|
||||
|
|
Loading…
Reference in a new issue