Merge branch 'dev-1.2' into development

This commit is contained in:
anhefti 2021-08-12 16:49:11 +02:00
commit 6dacca72c3
15 changed files with 523 additions and 132 deletions

View file

@ -23,6 +23,7 @@ public class ProctoringRoomConnection {
public static final String ATTR_ROOM_NAME = "roomName"; public static final String ATTR_ROOM_NAME = "roomName";
public static final String ATTR_SUBJECT = "subject"; public static final String ATTR_SUBJECT = "subject";
public static final String ATTR_ACCESS_TOKEN = "accessToken"; public static final String ATTR_ACCESS_TOKEN = "accessToken";
public static final String ATTR_SDK_TOKEN = "sdkToken";
public static final String ATTR_CONNECTION_URL = "connectionURL"; public static final String ATTR_CONNECTION_URL = "connectionURL";
public static final String ATTR_USER_NAME = "userName"; public static final String ATTR_USER_NAME = "userName";
public static final String ATTR_ROOM_KEY = "roomKey"; public static final String ATTR_ROOM_KEY = "roomKey";
@ -50,6 +51,9 @@ public class ProctoringRoomConnection {
@JsonProperty(ATTR_ACCESS_TOKEN) @JsonProperty(ATTR_ACCESS_TOKEN)
public final CharSequence accessToken; public final CharSequence accessToken;
@JsonProperty(ATTR_SDK_TOKEN)
public final CharSequence sdkToken;
@JsonProperty(ATTR_ROOM_KEY) @JsonProperty(ATTR_ROOM_KEY)
public final CharSequence roomKey; public final CharSequence roomKey;
@ -71,6 +75,7 @@ public class ProctoringRoomConnection {
@JsonProperty(ATTR_ROOM_NAME) final String roomName, @JsonProperty(ATTR_ROOM_NAME) final String roomName,
@JsonProperty(ATTR_SUBJECT) final String subject, @JsonProperty(ATTR_SUBJECT) final String subject,
@JsonProperty(ATTR_ACCESS_TOKEN) final CharSequence accessToken, @JsonProperty(ATTR_ACCESS_TOKEN) final CharSequence accessToken,
@JsonProperty(ATTR_SDK_TOKEN) final CharSequence sdkToken,
@JsonProperty(ATTR_ROOM_KEY) final CharSequence roomKey, @JsonProperty(ATTR_ROOM_KEY) final CharSequence roomKey,
@JsonProperty(ATTR_API_KEY) final CharSequence apiKey, @JsonProperty(ATTR_API_KEY) final CharSequence apiKey,
@JsonProperty(ATTR_MEETING_ID) final String meetingId, @JsonProperty(ATTR_MEETING_ID) final String meetingId,
@ -83,6 +88,7 @@ public class ProctoringRoomConnection {
this.roomName = roomName; this.roomName = roomName;
this.subject = subject; this.subject = subject;
this.accessToken = accessToken; this.accessToken = accessToken;
this.sdkToken = sdkToken;
this.roomKey = roomKey; this.roomKey = roomKey;
this.apiKey = apiKey; this.apiKey = apiKey;
this.meetingId = meetingId; this.meetingId = meetingId;
@ -105,6 +111,10 @@ public class ProctoringRoomConnection {
return this.accessToken; return this.accessToken;
} }
public CharSequence getSdkToken() {
return this.sdkToken;
}
public CharSequence getRoomKey() { public CharSequence getRoomKey() {
return this.roomKey; return this.roomKey;
} }

View file

@ -43,6 +43,8 @@ public class ProctoringServiceSettings implements Entity {
public static final String ATTR_SERVER_URL = "serverURL"; public static final String ATTR_SERVER_URL = "serverURL";
public static final String ATTR_APP_KEY = "appKey"; public static final String ATTR_APP_KEY = "appKey";
public static final String ATTR_APP_SECRET = "appSecret"; public static final String ATTR_APP_SECRET = "appSecret";
public static final String ATTR_SDK_KEY = "sdkKey";
public static final String ATTR_SDK_SECRET = "sdkSecret";
public static final String ATTR_COLLECTING_ROOM_SIZE = "collectingRoomSize"; public static final String ATTR_COLLECTING_ROOM_SIZE = "collectingRoomSize";
public static final String ATTR_ENABLED_FEATURES = "enabledFeatures"; public static final String ATTR_ENABLED_FEATURES = "enabledFeatures";
public static final String ATTR_COLLECT_ALL_ROOM_NAME = "collectAllRoomName"; public static final String ATTR_COLLECT_ALL_ROOM_NAME = "collectAllRoomName";
@ -67,6 +69,12 @@ public class ProctoringServiceSettings implements Entity {
@JsonProperty(ATTR_APP_SECRET) @JsonProperty(ATTR_APP_SECRET)
public final CharSequence appSecret; public final CharSequence appSecret;
@JsonProperty(ATTR_SDK_KEY)
public final String sdkKey;
@JsonProperty(ATTR_SDK_SECRET)
public final CharSequence sdkSecret;
@JsonProperty(ATTR_COLLECTING_ROOM_SIZE) @JsonProperty(ATTR_COLLECTING_ROOM_SIZE)
public final Integer collectingRoomSize; public final Integer collectingRoomSize;
@ -86,7 +94,9 @@ public class ProctoringServiceSettings implements Entity {
@JsonProperty(ATTR_ENABLED_FEATURES) final EnumSet<ProctoringFeature> enabledFeatures, @JsonProperty(ATTR_ENABLED_FEATURES) final EnumSet<ProctoringFeature> enabledFeatures,
@JsonProperty(ATTR_SERVICE_IN_USE) final Boolean serviceInUse, @JsonProperty(ATTR_SERVICE_IN_USE) final Boolean serviceInUse,
@JsonProperty(ATTR_APP_KEY) final String appKey, @JsonProperty(ATTR_APP_KEY) final String appKey,
@JsonProperty(ATTR_APP_SECRET) final CharSequence appSecret) { @JsonProperty(ATTR_APP_SECRET) final CharSequence appSecret,
@JsonProperty(ATTR_SDK_KEY) final String sdkKey,
@JsonProperty(ATTR_SDK_SECRET) final CharSequence sdkSecret) {
this.examId = examId; this.examId = examId;
this.enableProctoring = BooleanUtils.isTrue(enableProctoring); this.enableProctoring = BooleanUtils.isTrue(enableProctoring);
@ -97,6 +107,8 @@ public class ProctoringServiceSettings implements Entity {
this.serviceInUse = serviceInUse; this.serviceInUse = serviceInUse;
this.appKey = appKey; this.appKey = appKey;
this.appSecret = appSecret; this.appSecret = appSecret;
this.sdkKey = sdkKey;
this.sdkSecret = sdkSecret;
} }
@ -147,6 +159,14 @@ public class ProctoringServiceSettings implements Entity {
return this.appSecret; return this.appSecret;
} }
public String getSdkKey() {
return this.sdkKey;
}
public CharSequence getSdkSecret() {
return this.sdkSecret;
}
public Boolean getServiceInUse() { public Boolean getServiceInUse() {
return this.serviceInUse; return this.serviceInUse;
} }
@ -193,7 +213,11 @@ public class ProctoringServiceSettings implements Entity {
builder.append(", appKey="); builder.append(", appKey=");
builder.append(this.appKey); builder.append(this.appKey);
builder.append(", appSecret="); builder.append(", appSecret=");
builder.append(this.appSecret); builder.append("--");
builder.append(", sdkKey=");
builder.append(this.sdkKey);
builder.append(", sdkSecret=");
builder.append("--");
builder.append(", collectingRoomSize="); builder.append(", collectingRoomSize=");
builder.append(this.collectingRoomSize); builder.append(this.collectingRoomSize);
builder.append(", enabledFeatures="); builder.append(", enabledFeatures=");

View file

@ -63,7 +63,7 @@ public final class LmsSetup implements GrantEntity, Activatable {
/** The Ans Delft binding is on the way */ /** The Ans Delft binding is on the way */
ANS_DELFT(), ANS_DELFT(),
/** The OpenOLAT binding is on the way */ /** The OpenOLAT binding is on the way */
OPEN_OLAT(); OPEN_OLAT(Features.COURSE_API, Features.SEB_RESTRICTION);
public final EnumSet<Features> features; public final EnumSet<Features> features;

View file

@ -54,6 +54,7 @@ public final class ClientInstruction {
public static final String ZOOM_USER_NAME = "zoomUserName"; public static final String ZOOM_USER_NAME = "zoomUserName";
public static final String ZOOM_API_KEY = "zoomAPIKey"; public static final String ZOOM_API_KEY = "zoomAPIKey";
public static final String ZOOM_TOKEN = "zoomToken"; public static final String ZOOM_TOKEN = "zoomToken";
public static final String ZOOM_SDK_TOKEN = "zoomSDKToken";
public static final String ZOOM_MEETING_KEY = "zoomMeetingKey"; public static final String ZOOM_MEETING_KEY = "zoomMeetingKey";
public static final String ZOOM_RECEIVE_AUDIO = "zoomReceiveAudio"; public static final String ZOOM_RECEIVE_AUDIO = "zoomReceiveAudio";
public static final String ZOOM_RECEIVE_VIDEO = "zoomReceiveVideo"; public static final String ZOOM_RECEIVE_VIDEO = "zoomReceiveVideo";

View file

@ -72,6 +72,11 @@ public class ExamProctoringSettings {
new LocTextKey("sebserver.exam.proctoring.form.appkey"); new LocTextKey("sebserver.exam.proctoring.form.appkey");
private final static LocTextKey SEB_PROCTORING_FORM_SECRET = private final static LocTextKey SEB_PROCTORING_FORM_SECRET =
new LocTextKey("sebserver.exam.proctoring.form.secret"); new LocTextKey("sebserver.exam.proctoring.form.secret");
private final static LocTextKey SEB_PROCTORING_FORM_SDKKEY =
new LocTextKey("sebserver.exam.proctoring.form.sdkkey");
private final static LocTextKey SEB_PROCTORING_FORM_SDKSECRET =
new LocTextKey("sebserver.exam.proctoring.form.sdksecret");
private final static LocTextKey SEB_PROCTORING_FORM_FEATURES = private final static LocTextKey SEB_PROCTORING_FORM_FEATURES =
new LocTextKey("sebserver.exam.proctoring.form.features"); new LocTextKey("sebserver.exam.proctoring.form.features");
@ -155,7 +160,9 @@ public class ExamProctoringSettings {
featureFlags, featureFlags,
false, false,
form.getFieldValue(ProctoringServiceSettings.ATTR_APP_KEY), form.getFieldValue(ProctoringServiceSettings.ATTR_APP_KEY),
form.getFieldValue(ProctoringServiceSettings.ATTR_APP_SECRET)); form.getFieldValue(ProctoringServiceSettings.ATTR_APP_SECRET),
form.getFieldValue(ProctoringServiceSettings.ATTR_SDK_KEY),
form.getFieldValue(ProctoringServiceSettings.ATTR_SDK_SECRET));
} catch (final Exception e) { } catch (final Exception e) {
log.error("Unexpected error while trying to get settings from form: ", e); log.error("Unexpected error while trying to get settings from form: ", e);
@ -225,6 +232,8 @@ public class ExamProctoringSettings {
.copyOf(content) .copyOf(content)
.clearEntityKeys(); .clearEntityKeys();
final boolean isZoom = proctoringSettings.serverType == ProctoringServerType.ZOOM;
final FormHandle<ProctoringServiceSettings> formHandle = this.pageService.formBuilder( final FormHandle<ProctoringServiceSettings> formHandle = this.pageService.formBuilder(
formContext) formContext)
.withDefaultSpanInput(5) .withDefaultSpanInput(5)
@ -249,7 +258,8 @@ public class ExamProctoringSettings {
ProctoringServiceSettings.ATTR_SERVER_TYPE, ProctoringServiceSettings.ATTR_SERVER_TYPE,
SEB_PROCTORING_FORM_TYPE, SEB_PROCTORING_FORM_TYPE,
proctoringSettings.serverType.name(), proctoringSettings.serverType.name(),
resourceService::examProctoringTypeResources)) resourceService::examProctoringTypeResources)
.withSelectionListener(this::serviceSelection))
.addField(FormBuilder.text( .addField(FormBuilder.text(
ProctoringServiceSettings.ATTR_SERVER_URL, ProctoringServiceSettings.ATTR_SERVER_URL,
@ -269,6 +279,21 @@ public class ExamProctoringSettings {
? String.valueOf(proctoringSettings.appSecret) ? String.valueOf(proctoringSettings.appSecret)
: null)) : null))
.addField(FormBuilder.text(
ProctoringServiceSettings.ATTR_SDK_KEY,
SEB_PROCTORING_FORM_SDKKEY,
proctoringSettings.sdkKey)
.visibleIf(isZoom))
.withEmptyCellSeparation(false)
.addField(FormBuilder.password(
ProctoringServiceSettings.ATTR_SDK_SECRET,
SEB_PROCTORING_FORM_SDKSECRET,
(proctoringSettings.sdkSecret != null)
? String.valueOf(proctoringSettings.sdkSecret)
: null)
.visibleIf(isZoom))
.withDefaultSpanInput(1) .withDefaultSpanInput(1)
.addField(FormBuilder.text( .addField(FormBuilder.text(
ProctoringServiceSettings.ATTR_COLLECTING_ROOM_SIZE, ProctoringServiceSettings.ATTR_COLLECTING_ROOM_SIZE,
@ -294,6 +319,14 @@ public class ExamProctoringSettings {
return () -> formHandle; return () -> formHandle;
} }
private void serviceSelection(final Form form) {
final boolean isZoom = ProctoringServerType.ZOOM.name()
.equals(form.getFieldValue(ProctoringServiceSettings.ATTR_SERVER_TYPE));
form.setFieldVisible(isZoom, ProctoringServiceSettings.ATTR_SDK_KEY);
form.setFieldVisible(isZoom, ProctoringServiceSettings.ATTR_SDK_SECRET);
}
} }
} }

View file

@ -213,7 +213,9 @@ public class ExamAdminServiceImpl implements ExamAdminService {
getEnabledFeatures(mapping), getEnabledFeatures(mapping),
this.remoteProctoringRoomDAO.isServiceInUse(examId).getOr(true), this.remoteProctoringRoomDAO.isServiceInUse(examId).getOr(true),
getString(mapping, ProctoringServiceSettings.ATTR_APP_KEY), getString(mapping, ProctoringServiceSettings.ATTR_APP_KEY),
getString(mapping, ProctoringServiceSettings.ATTR_APP_SECRET)); getString(mapping, ProctoringServiceSettings.ATTR_APP_SECRET),
getString(mapping, ProctoringServiceSettings.ATTR_SDK_KEY),
getString(mapping, ProctoringServiceSettings.ATTR_SDK_SECRET));
}); });
} }
@ -263,6 +265,22 @@ public class ExamAdminServiceImpl implements ExamAdminService {
.getOrThrow() .getOrThrow()
.toString()); .toString());
if (StringUtils.isNotBlank(proctoringServiceSettings.appKey)) {
this.additionalAttributesDAO.saveAdditionalAttribute(
EntityType.EXAM,
examId,
ProctoringServiceSettings.ATTR_SDK_KEY,
proctoringServiceSettings.sdkKey);
this.additionalAttributesDAO.saveAdditionalAttribute(
EntityType.EXAM,
examId,
ProctoringServiceSettings.ATTR_SDK_SECRET,
this.cryptor.encrypt(proctoringServiceSettings.sdkSecret)
.getOrThrow()
.toString());
}
this.additionalAttributesDAO.saveAdditionalAttribute( this.additionalAttributesDAO.saveAdditionalAttribute(
EntityType.EXAM, EntityType.EXAM,
examId, examId,

View file

@ -10,19 +10,28 @@ package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.olat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime; import org.joda.time.DateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.CacheManager; import org.springframework.cache.CacheManager;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsResourceDetails; import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsResourceDetails;
import org.springframework.web.client.RestTemplate;
import ch.ethz.seb.sebserver.ClientHttpRequestFactoryService; import ch.ethz.seb.sebserver.ClientHttpRequestFactoryService;
import ch.ethz.seb.sebserver.gbl.api.APIMessage; import ch.ethz.seb.sebserver.gbl.api.APIMessage;
@ -36,7 +45,6 @@ import ch.ethz.seb.sebserver.gbl.model.exam.Exam;
import ch.ethz.seb.sebserver.gbl.model.exam.QuizData; import ch.ethz.seb.sebserver.gbl.model.exam.QuizData;
import ch.ethz.seb.sebserver.gbl.model.exam.SEBRestriction; import ch.ethz.seb.sebserver.gbl.model.exam.SEBRestriction;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup; import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup.Features;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup.LmsType; 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.model.institution.LmsSetupTestResult;
import ch.ethz.seb.sebserver.gbl.model.user.ExamineeAccountDetails; import ch.ethz.seb.sebserver.gbl.model.user.ExamineeAccountDetails;
@ -47,19 +55,23 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.lms.APITemplateDataSupplier
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPIService; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPIService;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPITemplate; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPITemplate;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.AbstractCachedCourseAccess; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.AbstractCachedCourseAccess;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.olat.OlatLmsData.AssessmentData;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.olat.OlatLmsData.RestrictionData;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.olat.OlatLmsData.RestrictionDataPost;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.olat.OlatLmsData.UserData;
public class OlatLmsAPITemplate extends AbstractCachedCourseAccess implements LmsAPITemplate { public class OlatLmsAPITemplate extends AbstractCachedCourseAccess implements LmsAPITemplate {
// TODO add needed dependencies here private static final Logger log = LoggerFactory.getLogger(OlatLmsAPITemplate.class);
private final ClientHttpRequestFactoryService clientHttpRequestFactoryService; private final ClientHttpRequestFactoryService clientHttpRequestFactoryService;
private final ClientCredentialService clientCredentialService; private final ClientCredentialService clientCredentialService;
private final APITemplateDataSupplier apiTemplateDataSupplier; private final APITemplateDataSupplier apiTemplateDataSupplier;
private final Long lmsSetupId; private final Long lmsSetupId;
private OlatLmsRestTemplate cachedRestTemplate;
protected OlatLmsAPITemplate( protected OlatLmsAPITemplate(
// TODO if you need more dependencies inject them here and set the reference
final ClientHttpRequestFactoryService clientHttpRequestFactoryService, final ClientHttpRequestFactoryService clientHttpRequestFactoryService,
final ClientCredentialService clientCredentialService, final ClientCredentialService clientCredentialService,
final APITemplateDataSupplier apiTemplateDataSupplier, final APITemplateDataSupplier apiTemplateDataSupplier,
@ -95,32 +107,19 @@ public class OlatLmsAPITemplate extends AbstractCachedCourseAccess implements Lm
final LmsSetupTestResult testLmsSetupSettings = testLmsSetupSettings(); final LmsSetupTestResult testLmsSetupSettings = testLmsSetupSettings();
if (testLmsSetupSettings.hasAnyError()) { if (testLmsSetupSettings.hasAnyError()) {
return testLmsSetupSettings; return testLmsSetupSettings;
} else {
} }
try {
// TODO check if the course API of the remote LMS is available this.getRestTemplate().get();
// if not, create corresponding LmsSetupTestResult error } catch (final Exception e) {
return LmsSetupTestResult.ofQuizAccessAPIError(LmsType.OPEN_OLAT, "TODO: implement LMS access check"); log.error("Failed to access OLAT course API: ", e);
return LmsSetupTestResult.ofQuizAccessAPIError(LmsType.OPEN_OLAT, e.getMessage());
//return LmsSetupTestResult.ofOkay(LmsType.OPEN_OLAT); }
return LmsSetupTestResult.ofOkay(LmsType.OPEN_OLAT);
} }
@Override @Override
public LmsSetupTestResult testCourseRestrictionAPI() { public LmsSetupTestResult testCourseRestrictionAPI() {
final LmsSetupTestResult testLmsSetupSettings = testLmsSetupSettings(); return testCourseAccessAPI();
if (testLmsSetupSettings.hasAnyError()) {
return testLmsSetupSettings;
}
if (LmsType.OPEN_OLAT.features.contains(Features.SEB_RESTRICTION)) {
// TODO check if the course API of the remote LMS is available
// if not, create corresponding LmsSetupTestResult error
}
return LmsSetupTestResult.ofOkay(LmsType.OPEN_OLAT);
} }
private LmsSetupTestResult testLmsSetupSettings() { private LmsSetupTestResult testLmsSetupSettings() {
@ -207,62 +206,100 @@ public class OlatLmsAPITemplate extends AbstractCachedCourseAccess implements Lm
@Override @Override
protected Supplier<List<QuizData>> allQuizzesSupplier(final FilterMap filterMap) { protected Supplier<List<QuizData>> allQuizzesSupplier(final FilterMap filterMap) {
@SuppressWarnings("unused")
final String quizName = filterMap.getString(QuizData.FILTER_ATTR_QUIZ_NAME);
@SuppressWarnings("unused")
final DateTime quizFromTime = (filterMap != null) ? filterMap.getQuizFromTime() : null;
return () -> { return () -> {
final List<QuizData> res = getRestTemplate()
// TODO Get all course / quiz data from remote LMS that matches the filter criteria. .map(t -> this.collectAllQuizzes(t, filterMap))
// If the LMS API uses paging, go through all pages using the filter criteria .getOrThrow();
// and collect the course data. super.putToCache(res);
// Transform the data from courses / quizzes from LMS into QuizData objects return res;
// Put loaded QuizData objects to the cache: super.putToCache(quizDataCollection);
// before returning it.
throw new RuntimeException("TODO");
}; };
} }
private String examUrl(final long olatRepositoryId) {
final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
return lmsSetup.lmsApiUrl + "/auth/RepositoryEntry/" + olatRepositoryId;
}
private List<QuizData> collectAllQuizzes(final OlatLmsRestTemplate restTemplate, final FilterMap filterMap) {
final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
final String quizName = filterMap.getString(QuizData.FILTER_ATTR_QUIZ_NAME);
final DateTime quizFromTime = (filterMap != null) ? filterMap.getQuizFromTime() : null;
final long fromCutTime = (quizFromTime != null) ? Utils.toUnixTimeInSeconds(quizFromTime) : -1;
String url = "/restapi/assessment_modes/seb?";
if (fromCutTime != -1) {
url = String.format("%sdateFrom=%s&", url, fromCutTime);
}
if (quizName != null) {
url = String.format("%sname=%s&", url, quizName);
}
final List<AssessmentData> as =
this.apiGetList(restTemplate, url, new ParameterizedTypeReference<List<AssessmentData>>() {
});
return as.stream()
.map(a -> {
return new QuizData(
String.format("%d", a.key),
lmsSetup.getInstitutionId(),
lmsSetup.id,
lmsSetup.getLmsType(),
a.name,
a.description,
Utils.toDateTimeUTC(a.dateFrom),
Utils.toDateTimeUTC(a.dateTo),
examUrl(a.repositoryEntryKey),
new HashMap<String, String>());
})
.collect(Collectors.toList());
}
@Override @Override
protected Supplier<Collection<QuizData>> quizzesSupplier(final Set<String> ids) { protected Supplier<Collection<QuizData>> quizzesSupplier(final Set<String> ids) {
return () -> ids.stream().map(id -> quizSupplier(id).get()).collect(Collectors.toList());
return () -> {
// TODO get all quiz / course data for specified identifiers from remote LMS
// Transform the data from courses / quizzes from LMS into QuizData objects
// and put it to the cache: super.putToCache(quizDataCollection);
// before returning it.
throw new RuntimeException("TODO");
};
} }
@Override @Override
protected Supplier<QuizData> quizSupplier(final String id) { protected Supplier<QuizData> quizSupplier(final String id) {
return () -> getRestTemplate()
.map(t -> this.quizById(t, id))
.getOrThrow();
}
return () -> { private QuizData quizById(final OlatLmsRestTemplate restTemplate, final String id) {
final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
final String url = String.format("/restapi/assessment_modes/%s", id);
final AssessmentData a = this.apiGet(restTemplate, url, AssessmentData.class);
return new QuizData(
String.format("%d", a.key),
lmsSetup.getInstitutionId(),
lmsSetup.id,
lmsSetup.getLmsType(),
a.name,
a.description,
Utils.toDateTimeUTC(a.dateFrom),
Utils.toDateTimeUTC(a.dateTo),
examUrl(a.repositoryEntryKey),
new HashMap<String, String>());
}
// TODO get the specified quiz / course data for specified identifier from remote LMS private ExamineeAccountDetails getExamineeById(final RestTemplate restTemplate, final String id) {
// and put it to the cache: super.putToCache(quizDataCollection); final String url = String.format("/restapi/users/%s/name_username", id);
// before returning it. final UserData u = this.apiGet(restTemplate, url, UserData.class);
final Map<String, String> attrs = new HashMap<>();
throw new RuntimeException("TODO"); return new ExamineeAccountDetails(
}; String.valueOf(u.key),
u.lastName + ", " + u.firstName,
u.username,
"OLAT API does not provide email addresses",
attrs);
} }
@Override @Override
protected Supplier<ExamineeAccountDetails> accountDetailsSupplier(final String examineeSessionId) { protected Supplier<ExamineeAccountDetails> accountDetailsSupplier(final String id) {
return () -> getRestTemplate()
return () -> { .map(t -> this.getExamineeById(t, id))
.getOrThrow();
// TODO get the examinee's account details by the given examineeSessionId from remote LMS.
// Currently only the name is needed to display on monitoring view.
throw new RuntimeException("TODO");
};
} }
@Override @Override
@ -272,78 +309,130 @@ public class OlatLmsAPITemplate extends AbstractCachedCourseAccess implements Lm
}; };
} }
private SEBRestriction getRestrictionForAssignmentId(final RestTemplate restTemplate, final String id) {
final String url = String.format("/restapi/assessment_modes/%s/seb_restriction", id);
final RestrictionData r = this.apiGet(restTemplate, url, RestrictionData.class);
return new SEBRestriction(Long.valueOf(id), r.configKeys, r.browserExamKeys, new HashMap<String, String>());
}
private SEBRestriction setRestrictionForAssignmentId(
final RestTemplate restTemplate,
final String id,
final SEBRestriction restriction) {
final String url = String.format("/restapi/assessment_modes/%s/seb_restriction", id);
final RestrictionDataPost post = new RestrictionDataPost();
post.browserExamKeys = new ArrayList<>(restriction.browserExamKeys);
post.configKeys = new ArrayList<>(restriction.configKeys);
final RestrictionData r =
this.apiPost(restTemplate, url, post, RestrictionDataPost.class, RestrictionData.class);
return new SEBRestriction(Long.valueOf(id), r.configKeys, r.browserExamKeys, new HashMap<String, String>());
}
private SEBRestriction deleteRestrictionForAssignmentId(final RestTemplate restTemplate, final String id) {
final String url = String.format("/restapi/assessment_modes/%s/seb_restriction", id);
final RestrictionData r = this.apiDelete(restTemplate, url, RestrictionData.class);
// OLAT returns RestrictionData with null values upon deletion.
// We return it here for consistency, even though SEB server does not need it
return new SEBRestriction(Long.valueOf(id), r.configKeys, r.browserExamKeys, new HashMap<String, String>());
}
@Override @Override
public Result<SEBRestriction> getSEBClientRestriction(final Exam exam) { public Result<SEBRestriction> getSEBClientRestriction(final Exam exam) {
@SuppressWarnings("unused") return getRestTemplate()
final String quizId = exam.externalId; .map(t -> this.getRestrictionForAssignmentId(t, exam.externalId));
return Result.tryCatch(() -> {
// TODO get the SEB client restrictions that are currently set on the remote LMS for
// the given quiz / course derived from the given exam
throw new RuntimeException("TODO");
});
} }
@Override @Override
public Result<SEBRestriction> applySEBClientRestriction( public Result<SEBRestriction> applySEBClientRestriction(
final String externalExamId, final String externalExamId,
final SEBRestriction sebRestrictionData) { final SEBRestriction sebRestrictionData) {
return getRestTemplate()
return Result.tryCatch(() -> { .map(t -> this.setRestrictionForAssignmentId(t, externalExamId, sebRestrictionData));
// TODO apply the given sebRestrictionData settings as current SEB client restriction setting
// to the remote LMS for the given quiz / course.
// Mainly SEBRestriction.configKeys and SEBRestriction.browserExamKeys
throw new RuntimeException("TODO");
});
} }
@Override @Override
public Result<Exam> releaseSEBClientRestriction(final Exam exam) { public Result<Exam> releaseSEBClientRestriction(final Exam exam) {
@SuppressWarnings("unused") return getRestTemplate()
final String quizId = exam.externalId; .map(t -> this.deleteRestrictionForAssignmentId(t, exam.externalId))
.map(x -> exam);
}
private <T> T apiGet(final RestTemplate restTemplate, final String url, final Class<T> type) {
final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
final ResponseEntity<T> res = restTemplate.exchange(
lmsSetup.lmsApiUrl + url,
HttpMethod.GET,
HttpEntity.EMPTY,
type);
return res.getBody();
}
private <T> List<T> apiGetList(final RestTemplate restTemplate, final String url,
final ParameterizedTypeReference<List<T>> type) {
final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
final ResponseEntity<List<T>> res = restTemplate.exchange(
lmsSetup.lmsApiUrl + url,
HttpMethod.GET,
HttpEntity.EMPTY,
type);
return res.getBody();
}
private <P, R> R apiPost(final RestTemplate restTemplate, final String url, final P post, final Class<P> postType,
final Class<R> responseType) {
final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
final HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.set("content-type", "application/json");
final HttpEntity<P> requestEntity = new HttpEntity<>(post, httpHeaders);
final ResponseEntity<R> res = restTemplate.exchange(
lmsSetup.lmsApiUrl + url,
HttpMethod.POST,
requestEntity,
responseType);
return res.getBody();
}
private <T> T apiDelete(final RestTemplate restTemplate, final String url, final Class<T> type) {
final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
final ResponseEntity<T> res = restTemplate.exchange(
lmsSetup.lmsApiUrl + url,
HttpMethod.DELETE,
HttpEntity.EMPTY,
type);
return res.getBody();
}
private Result<OlatLmsRestTemplate> getRestTemplate() {
return Result.tryCatch(() -> { return Result.tryCatch(() -> {
if (this.cachedRestTemplate != null) {
return this.cachedRestTemplate;
}
// TODO Release respectively delete all SEB client restrictions for the given final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
// course / quize on the remote LMS. final ClientCredentials credentials = this.apiTemplateDataSupplier.getLmsClientCredentials();
final ProxyData proxyData = this.apiTemplateDataSupplier.getProxyData();
throw new RuntimeException("TODO"); final CharSequence plainClientId = credentials.clientId;
final CharSequence plainClientSecret = this.clientCredentialService
.getPlainClientSecret(credentials)
.getOrThrow();
final ClientCredentialsResourceDetails details = new ClientCredentialsResourceDetails();
details.setAccessTokenUri(lmsSetup.lmsApiUrl + "/restapi/auth/");
details.setClientId(plainClientId.toString());
details.setClientSecret(plainClientSecret.toString());
final ClientHttpRequestFactory clientHttpRequestFactory = this.clientHttpRequestFactoryService
.getClientHttpRequestFactory(proxyData)
.getOrThrow();
final OlatLmsRestTemplate template = new OlatLmsRestTemplate(details);
template.setRequestFactory(clientHttpRequestFactory);
this.cachedRestTemplate = template;
return this.cachedRestTemplate;
}); });
} }
// TODO: This is an example of how to create a RestTemplate for the service to access the LMS API
// The example deals with a Http based API that is secured by an OAuth2 client-credential flow.
// You might need some different template, then you have to adapt this code
// To your needs.
@SuppressWarnings("unused")
private OAuth2RestTemplate createRestTemplate(final String accessTokenRequestPath) {
final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
final ClientCredentials credentials = this.apiTemplateDataSupplier.getLmsClientCredentials();
final ProxyData proxyData = this.apiTemplateDataSupplier.getProxyData();
final CharSequence plainClientId = credentials.clientId;
final CharSequence plainClientSecret = this.clientCredentialService
.getPlainClientSecret(credentials)
.getOrThrow();
final ClientCredentialsResourceDetails details = new ClientCredentialsResourceDetails();
details.setAccessTokenUri(lmsSetup.lmsApiUrl + accessTokenRequestPath);
details.setClientId(plainClientId.toString());
details.setClientSecret(plainClientSecret.toString());
final ClientHttpRequestFactory clientHttpRequestFactory = this.clientHttpRequestFactoryService
.getClientHttpRequestFactory(proxyData)
.getOrThrow();
final OAuth2RestTemplate template = new OAuth2RestTemplate(details);
template.setRequestFactory(clientHttpRequestFactory);
return template;
}
} }

View file

@ -0,0 +1,85 @@
/*
* Copyright (c) 2021 ETH Zürich, Educational Development and Technology (LET)
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.olat;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
public final class OlatLmsData {
@JsonIgnoreProperties(ignoreUnknown = true)
static public final class AssessmentData {
/*
* OLAT API example:
* {
* "courseName": "course 1",
* "dateFrom": 1624420800000,
* "dateTo": 1624658400000,
* "description": "",
* "key": 6356992,
* repositoryEntryKey: 462324,
* "name": "SEB test"
* }
*/
public long key;
public long repositoryEntryKey;
public String name;
public String description;
public String courseName;
public long dateFrom;
public long dateTo;
}
@JsonIgnoreProperties(ignoreUnknown = true)
static final class UserData {
/*
* OLAT API example:
* {
* "firstName": "OpenOLAT",
* "key": 360448,
* "lastName": "Administrator",
* "username": "administrator"
* }
*/
public long key;
public String firstName;
public String lastName;
public String username;
}
@JsonIgnoreProperties(ignoreUnknown = true)
static final class RestrictionData {
/*
* OLAT API example:
* {
* "browserExamKeys": [ "1" ],
* "configKeys": null,
* "key": 8028160
* }
*/
public long key;
public List<String> browserExamKeys;
public List<String> configKeys;
}
@JsonIgnoreProperties(ignoreUnknown = true)
static final class RestrictionDataPost {
/*
* OLAT API example:
* {
* "configKeys": ["a", "b"],
* "browserExamKeys": ["1", "2"]
* }
*/
public List<String> browserExamKeys;
public List<String> configKeys;
}
}

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2021 ETH Zürich, Educational Development and Technology (LET)
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.olat;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsResourceDetails;
import org.springframework.web.client.RestTemplate;
public class OlatLmsRestTemplate extends RestTemplate {
private static final Logger log = LoggerFactory.getLogger(OlatLmsRestTemplate.class);
private String token;
private ClientCredentialsResourceDetails details;
public OlatLmsRestTemplate(final ClientCredentialsResourceDetails details) {
super();
this.details = details;
// Add X-OLAT-TOKEN request header to every request done using this RestTemplate
this.getInterceptors().add(new ClientHttpRequestInterceptor() {
@Override
public ClientHttpResponse intercept(final HttpRequest request, final byte[] body,
final ClientHttpRequestExecution execution) throws IOException {
// if there's no token, authenticate first
if (OlatLmsRestTemplate.this.token == null) {
authenticate();
}
// when authenticating, just do a normal call
else if (OlatLmsRestTemplate.this.token.equals("authenticating")) {
return execution.execute(request, body);
}
// otherwise, add the X-OLAT-TOKEN
request.getHeaders().set("accept", "application/json");
request.getHeaders().set("X-OLAT-TOKEN", OlatLmsRestTemplate.this.token);
ClientHttpResponse response = execution.execute(request, body);
log.debug("OLAT [regular API call] {} Headers: {}", response.getStatusCode(), response.getHeaders());
// If we get a 401, re-authenticate and try once more
if (response.getStatusCode() == HttpStatus.UNAUTHORIZED) {
authenticate();
request.getHeaders().set("X-OLAT-TOKEN", OlatLmsRestTemplate.this.token);
response = execution.execute(request, body);
log.debug("OLAT [retry API call] {} Headers: {}", response.getStatusCode(), response.getHeaders());
}
return response;
}
});
}
private void authenticate() {
// Authenticate with OLAT and store the received X-OLAT-TOKEN
this.token = "authenticating";
final String authUrl = String.format("%s%s?password=%s",
this.details.getAccessTokenUri(),
this.details.getClientId(),
this.details.getClientSecret());
try {
final ResponseEntity<String> response = this.getForEntity(authUrl, String.class);
final HttpHeaders responseHeaders = response.getHeaders();
log.debug("OLAT [authenticate] {} Headers: {}", response.getStatusCode(), responseHeaders);
this.token = responseHeaders.getFirst("X-OLAT-TOKEN");
} catch (final Exception e) {
this.token = null;
throw e;
}
}
}

View file

@ -378,6 +378,7 @@ public class JitsiProctoringService implements ExamProctoringService {
null, null,
null, null,
null, null,
null,
clientName); clientName);
}); });
} }

View file

@ -214,6 +214,9 @@ public class ZoomProctoringService implements ExamProctoringService {
attributes.put( attributes.put(
ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.ZOOM_TOKEN, ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.ZOOM_TOKEN,
String.valueOf(proctoringConnection.accessToken)); String.valueOf(proctoringConnection.accessToken));
attributes.put(
ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.ZOOM_SDK_TOKEN,
String.valueOf(proctoringConnection.sdkToken));
if (StringUtils.isNotBlank(proctoringConnection.apiKey)) { if (StringUtils.isNotBlank(proctoringConnection.apiKey)) {
attributes.put( attributes.put(
ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.ZOOM_API_KEY, ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.ZOOM_API_KEY,
@ -262,6 +265,19 @@ public class ZoomProctoringService implements ExamProctoringService {
String.valueOf(additionalZoomRoomData.meeting_id), String.valueOf(additionalZoomRoomData.meeting_id),
true); true);
String sdkJWT = null;
if (StringUtils.isNotBlank(proctoringSettings.sdkKey)) {
final ClientCredentials sdkCredentials = new ClientCredentials(
proctoringSettings.sdkKey,
proctoringSettings.sdkSecret,
remoteProctoringRoom.joinKey);
sdkJWT = this.createJWTForSDKAccess(
sdkCredentials,
String.valueOf(additionalZoomRoomData.meeting_id));
}
return new ProctoringRoomConnection( return new ProctoringRoomConnection(
ProctoringServerType.ZOOM, ProctoringServerType.ZOOM,
null, null,
@ -270,6 +286,7 @@ public class ZoomProctoringService implements ExamProctoringService {
roomName, roomName,
subject, subject,
jwt, jwt,
sdkJWT,
credentials.accessToken, credentials.accessToken,
credentials.clientId, credentials.clientId,
String.valueOf(additionalZoomRoomData.meeting_id), String.valueOf(additionalZoomRoomData.meeting_id),
@ -308,6 +325,19 @@ public class ZoomProctoringService implements ExamProctoringService {
.getConnectionData(connectionToken) .getConnectionData(connectionToken)
.getOrThrow(); .getOrThrow();
String sdkJWT = null;
if (StringUtils.isNotBlank(proctoringSettings.sdkKey)) {
final ClientCredentials sdkCredentials = new ClientCredentials(
proctoringSettings.sdkKey,
proctoringSettings.sdkSecret,
remoteProctoringRoom.joinKey);
sdkJWT = this.createJWTForSDKAccess(
sdkCredentials,
String.valueOf(additionalZoomRoomData.meeting_id));
}
return new ProctoringRoomConnection( return new ProctoringRoomConnection(
ProctoringServerType.ZOOM, ProctoringServerType.ZOOM,
connectionToken, connectionToken,
@ -316,6 +346,7 @@ public class ZoomProctoringService implements ExamProctoringService {
roomName, roomName,
subject, subject,
jwt, jwt,
sdkJWT,
credentials.accessToken, credentials.accessToken,
credentials.clientId, credentials.clientId,
String.valueOf(additionalZoomRoomData.meeting_id), String.valueOf(additionalZoomRoomData.meeting_id),
@ -613,6 +644,13 @@ public class ZoomProctoringService implements ExamProctoringService {
} }
} }
private String createJWTForSDKAccess(
final ClientCredentials sdkCredentials,
final String meetingId) {
return createJWTForMeetingAccess(sdkCredentials, meetingId, false);
}
private String createJWTForMeetingAccess( private String createJWTForMeetingAccess(
final ClientCredentials credentials, final ClientCredentials credentials,
final String meetingId, final String meetingId,

View file

@ -661,10 +661,14 @@ sebserver.exam.proctoring.form.url=Server URL
sebserver.exam.proctoring.form.url.tooltip=The proctoring server URL sebserver.exam.proctoring.form.url.tooltip=The proctoring server URL
sebserver.exam.proctoring.form.collectingRoomSize=Collecting Room Size sebserver.exam.proctoring.form.collectingRoomSize=Collecting Room Size
sebserver.exam.proctoring.form.collectingRoomSize.tooltip=The size of proctor rooms to collect connecting SEB clients into. sebserver.exam.proctoring.form.collectingRoomSize.tooltip=The size of proctor rooms to collect connecting SEB clients into.
sebserver.exam.proctoring.form.appkey=Application Key sebserver.exam.proctoring.form.appkey=App Key
sebserver.exam.proctoring.form.appkey.tooltip=The application key of the proctoring service server sebserver.exam.proctoring.form.appkey.tooltip=The application key of the proctoring service server
sebserver.exam.proctoring.form.secret=Secret sebserver.exam.proctoring.form.secret=App Secret
sebserver.exam.proctoring.form.secret.tooltip=The secret used to access the proctoring service sebserver.exam.proctoring.form.secret.tooltip=The secret used to access the proctoring service
sebserver.exam.proctoring.form.sdkkey=SDK Key (MacOS/iOS)
sebserver.exam.proctoring.form.sdkkey.tooltip=The SDK key and secret are used for live proctoring with SEB clients for iOS and/or MacOS
sebserver.exam.proctoring.form.sdksecret=SDK Secret (MacOS/iOS)
sebserver.exam.proctoring.form.sdksecret.tooltip=The SDK key and secret are used for live proctoring with SEB clients for iOS and/or MacOS
sebserver.exam.proctoring.form.features=Enabled Features sebserver.exam.proctoring.form.features=Enabled Features
sebserver.exam.proctoring.form.features.TOWN_HALL=Town-Hall Room sebserver.exam.proctoring.form.features.TOWN_HALL=Town-Hall Room
sebserver.exam.proctoring.form.features.ONE_TO_ONE=One to One Room sebserver.exam.proctoring.form.features.ONE_TO_ONE=One to One Room

View file

@ -37,6 +37,7 @@ public class JitsiWindowScriptResolverTest {
"ROOM", "ROOM",
"SUBJECT", "SUBJECT",
"ACCESS_TOKEN", "ACCESS_TOKEN",
null,
"API_KEY", "API_KEY",
"ROOM_KEY", "ROOM_KEY",
"MEETING_ID", "MEETING_ID",
@ -53,6 +54,7 @@ public class JitsiWindowScriptResolverTest {
"ROOM", "ROOM",
"SUBJECT", "SUBJECT",
"ACCESS_TOKEN", "ACCESS_TOKEN",
null,
"API_KEY", "API_KEY",
"ROOM_KEY", "ROOM_KEY",
"MEETING_ID", "MEETING_ID",

View file

@ -37,6 +37,7 @@ public class ZoomWindowScriptResolverTest {
"ROOM", "ROOM",
"SUBJECT", "SUBJECT",
"ACCESS_TOKEN", "ACCESS_TOKEN",
"SDK_TOKEN",
"API_KEY", "API_KEY",
"ROOM_KEY", "ROOM_KEY",
"MEETING_ID", "MEETING_ID",
@ -53,6 +54,7 @@ public class ZoomWindowScriptResolverTest {
"ROOM", "ROOM",
"SUBJECT", "SUBJECT",
"ACCESS_TOKEN", "ACCESS_TOKEN",
"SDK_TOKEN",
"API_KEY", "API_KEY",
"ROOM_KEY", "ROOM_KEY",
"MEETING_ID", "MEETING_ID",

View file

@ -63,7 +63,7 @@ public class ExamProctoringRoomServiceTest extends AdministrationAPIIntegrationT
2L, 2L,
new ProctoringServiceSettings( new ProctoringServiceSettings(
2L, true, ProctoringServerType.JITSI_MEET, "http://jitsi.ch", 1, null, false, 2L, true, ProctoringServerType.JITSI_MEET, "http://jitsi.ch", 1, null, false,
"app-key", "app.secret")); "app-key", "app.secret", "sdk-key", "sdk.secret"));
assertTrue(this.examAdminService.isProctoringEnabled(2L).get()); assertTrue(this.examAdminService.isProctoringEnabled(2L).get());
} }