diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/ProctoringRoomConnection.java b/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/ProctoringRoomConnection.java index 264858ef..8a8ac806 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/ProctoringRoomConnection.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/ProctoringRoomConnection.java @@ -23,6 +23,7 @@ public class ProctoringRoomConnection { public static final String ATTR_ROOM_NAME = "roomName"; public static final String ATTR_SUBJECT = "subject"; 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_USER_NAME = "userName"; public static final String ATTR_ROOM_KEY = "roomKey"; @@ -50,6 +51,9 @@ public class ProctoringRoomConnection { @JsonProperty(ATTR_ACCESS_TOKEN) public final CharSequence accessToken; + @JsonProperty(ATTR_SDK_TOKEN) + public final CharSequence sdkToken; + @JsonProperty(ATTR_ROOM_KEY) public final CharSequence roomKey; @@ -71,6 +75,7 @@ public class ProctoringRoomConnection { @JsonProperty(ATTR_ROOM_NAME) final String roomName, @JsonProperty(ATTR_SUBJECT) final String subject, @JsonProperty(ATTR_ACCESS_TOKEN) final CharSequence accessToken, + @JsonProperty(ATTR_SDK_TOKEN) final CharSequence sdkToken, @JsonProperty(ATTR_ROOM_KEY) final CharSequence roomKey, @JsonProperty(ATTR_API_KEY) final CharSequence apiKey, @JsonProperty(ATTR_MEETING_ID) final String meetingId, @@ -83,6 +88,7 @@ public class ProctoringRoomConnection { this.roomName = roomName; this.subject = subject; this.accessToken = accessToken; + this.sdkToken = sdkToken; this.roomKey = roomKey; this.apiKey = apiKey; this.meetingId = meetingId; @@ -105,6 +111,10 @@ public class ProctoringRoomConnection { return this.accessToken; } + public CharSequence getSdkToken() { + return this.sdkToken; + } + public CharSequence getRoomKey() { return this.roomKey; } diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/ProctoringServiceSettings.java b/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/ProctoringServiceSettings.java index 4d65b4fc..92c094fe 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/ProctoringServiceSettings.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/ProctoringServiceSettings.java @@ -43,6 +43,8 @@ public class ProctoringServiceSettings implements Entity { public static final String ATTR_SERVER_URL = "serverURL"; public static final String ATTR_APP_KEY = "appKey"; 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_ENABLED_FEATURES = "enabledFeatures"; public static final String ATTR_COLLECT_ALL_ROOM_NAME = "collectAllRoomName"; @@ -67,6 +69,12 @@ public class ProctoringServiceSettings implements Entity { @JsonProperty(ATTR_APP_SECRET) 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) public final Integer collectingRoomSize; @@ -86,7 +94,9 @@ public class ProctoringServiceSettings implements Entity { @JsonProperty(ATTR_ENABLED_FEATURES) final EnumSet enabledFeatures, @JsonProperty(ATTR_SERVICE_IN_USE) final Boolean serviceInUse, @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.enableProctoring = BooleanUtils.isTrue(enableProctoring); @@ -97,6 +107,8 @@ public class ProctoringServiceSettings implements Entity { this.serviceInUse = serviceInUse; this.appKey = appKey; this.appSecret = appSecret; + this.sdkKey = sdkKey; + this.sdkSecret = sdkSecret; } @@ -147,6 +159,14 @@ public class ProctoringServiceSettings implements Entity { return this.appSecret; } + public String getSdkKey() { + return this.sdkKey; + } + + public CharSequence getSdkSecret() { + return this.sdkSecret; + } + public Boolean getServiceInUse() { return this.serviceInUse; } @@ -193,7 +213,11 @@ public class ProctoringServiceSettings implements Entity { builder.append(", appKey="); builder.append(this.appKey); 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(this.collectingRoomSize); builder.append(", enabledFeatures="); diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetup.java b/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetup.java index d6dc48e8..223a53f3 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetup.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetup.java @@ -63,7 +63,7 @@ public final class LmsSetup implements GrantEntity, Activatable { /** The Ans Delft binding is on the way */ ANS_DELFT(), /** The OpenOLAT binding is on the way */ - OPEN_OLAT(); + OPEN_OLAT(Features.COURSE_API, Features.SEB_RESTRICTION); public final EnumSet features; diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/model/session/ClientInstruction.java b/src/main/java/ch/ethz/seb/sebserver/gbl/model/session/ClientInstruction.java index b75f02bc..aa111164 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/model/session/ClientInstruction.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/model/session/ClientInstruction.java @@ -54,6 +54,7 @@ public final class ClientInstruction { public static final String ZOOM_USER_NAME = "zoomUserName"; public static final String ZOOM_API_KEY = "zoomAPIKey"; 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_RECEIVE_AUDIO = "zoomReceiveAudio"; public static final String ZOOM_RECEIVE_VIDEO = "zoomReceiveVideo"; diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/ExamProctoringSettings.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/ExamProctoringSettings.java index a8771350..c2a07821 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/ExamProctoringSettings.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/ExamProctoringSettings.java @@ -72,6 +72,11 @@ public class ExamProctoringSettings { new LocTextKey("sebserver.exam.proctoring.form.appkey"); private final static LocTextKey SEB_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 = new LocTextKey("sebserver.exam.proctoring.form.features"); @@ -155,7 +160,9 @@ public class ExamProctoringSettings { featureFlags, false, 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) { log.error("Unexpected error while trying to get settings from form: ", e); @@ -225,6 +232,8 @@ public class ExamProctoringSettings { .copyOf(content) .clearEntityKeys(); + final boolean isZoom = proctoringSettings.serverType == ProctoringServerType.ZOOM; + final FormHandle formHandle = this.pageService.formBuilder( formContext) .withDefaultSpanInput(5) @@ -249,7 +258,8 @@ public class ExamProctoringSettings { ProctoringServiceSettings.ATTR_SERVER_TYPE, SEB_PROCTORING_FORM_TYPE, proctoringSettings.serverType.name(), - resourceService::examProctoringTypeResources)) + resourceService::examProctoringTypeResources) + .withSelectionListener(this::serviceSelection)) .addField(FormBuilder.text( ProctoringServiceSettings.ATTR_SERVER_URL, @@ -269,6 +279,21 @@ public class ExamProctoringSettings { ? String.valueOf(proctoringSettings.appSecret) : 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) .addField(FormBuilder.text( ProctoringServiceSettings.ATTR_COLLECTING_ROOM_SIZE, @@ -294,6 +319,14 @@ public class ExamProctoringSettings { 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); + } } } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/ExamAdminServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/ExamAdminServiceImpl.java index bacadf7b..9bef43bd 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/ExamAdminServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/ExamAdminServiceImpl.java @@ -213,7 +213,9 @@ public class ExamAdminServiceImpl implements ExamAdminService { getEnabledFeatures(mapping), this.remoteProctoringRoomDAO.isServiceInUse(examId).getOr(true), 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() .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( EntityType.EXAM, examId, diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/olat/OlatLmsAPITemplate.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/olat/OlatLmsAPITemplate.java index 7ecfa6a2..27408b08 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/olat/OlatLmsAPITemplate.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/olat/OlatLmsAPITemplate.java @@ -10,19 +10,28 @@ package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.olat; import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.function.Supplier; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.joda.time.DateTime; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.cache.CacheManager; +import org.springframework.core.ParameterizedTypeReference; 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.security.oauth2.client.OAuth2RestTemplate; 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.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.SEBRestriction; 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.LmsSetupTestResult; 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.LmsAPITemplate; 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 { - // TODO add needed dependencies here + private static final Logger log = LoggerFactory.getLogger(OlatLmsAPITemplate.class); + private final ClientHttpRequestFactoryService clientHttpRequestFactoryService; private final ClientCredentialService clientCredentialService; private final APITemplateDataSupplier apiTemplateDataSupplier; private final Long lmsSetupId; + private OlatLmsRestTemplate cachedRestTemplate; + protected OlatLmsAPITemplate( - - // TODO if you need more dependencies inject them here and set the reference - final ClientHttpRequestFactoryService clientHttpRequestFactoryService, final ClientCredentialService clientCredentialService, final APITemplateDataSupplier apiTemplateDataSupplier, @@ -95,32 +107,19 @@ public class OlatLmsAPITemplate extends AbstractCachedCourseAccess implements Lm final LmsSetupTestResult testLmsSetupSettings = testLmsSetupSettings(); if (testLmsSetupSettings.hasAnyError()) { return testLmsSetupSettings; - } else { - } - - // TODO check if the course API of the remote LMS is available - // if not, create corresponding LmsSetupTestResult error - return LmsSetupTestResult.ofQuizAccessAPIError(LmsType.OPEN_OLAT, "TODO: implement LMS access check"); - - //return LmsSetupTestResult.ofOkay(LmsType.OPEN_OLAT); + try { + this.getRestTemplate().get(); + } catch (final Exception e) { + log.error("Failed to access OLAT course API: ", e); + return LmsSetupTestResult.ofQuizAccessAPIError(LmsType.OPEN_OLAT, e.getMessage()); + } + return LmsSetupTestResult.ofOkay(LmsType.OPEN_OLAT); } @Override public LmsSetupTestResult testCourseRestrictionAPI() { - final LmsSetupTestResult testLmsSetupSettings = testLmsSetupSettings(); - 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); + return testCourseAccessAPI(); } private LmsSetupTestResult testLmsSetupSettings() { @@ -207,62 +206,100 @@ public class OlatLmsAPITemplate extends AbstractCachedCourseAccess implements Lm @Override protected Supplier> 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 () -> { - - // TODO Get all course / quiz data from remote LMS that matches the filter criteria. - // If the LMS API uses paging, go through all pages using the filter criteria - // and collect the course data. - // Transform the data from courses / quizzes from LMS into QuizData objects - // Put loaded QuizData objects to the cache: super.putToCache(quizDataCollection); - // before returning it. - - throw new RuntimeException("TODO"); + final List res = getRestTemplate() + .map(t -> this.collectAllQuizzes(t, filterMap)) + .getOrThrow(); + super.putToCache(res); + return res; }; } + private String examUrl(final long olatRepositoryId) { + final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup(); + return lmsSetup.lmsApiUrl + "/auth/RepositoryEntry/" + olatRepositoryId; + } + + private List 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 as = + this.apiGetList(restTemplate, url, new ParameterizedTypeReference>() { + }); + 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()); + }) + .collect(Collectors.toList()); + } + @Override protected Supplier> quizzesSupplier(final Set ids) { - - 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"); - }; + return () -> ids.stream().map(id -> quizSupplier(id).get()).collect(Collectors.toList()); } @Override protected Supplier 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()); + } - // TODO get the specified quiz / course data for specified identifier from remote LMS - // and put it to the cache: super.putToCache(quizDataCollection); - // before returning it. - - throw new RuntimeException("TODO"); - }; + private ExamineeAccountDetails getExamineeById(final RestTemplate restTemplate, final String id) { + final String url = String.format("/restapi/users/%s/name_username", id); + final UserData u = this.apiGet(restTemplate, url, UserData.class); + final Map attrs = new HashMap<>(); + return new ExamineeAccountDetails( + String.valueOf(u.key), + u.lastName + ", " + u.firstName, + u.username, + "OLAT API does not provide email addresses", + attrs); } @Override - protected Supplier accountDetailsSupplier(final String examineeSessionId) { - - return () -> { - - // 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"); - }; + protected Supplier accountDetailsSupplier(final String id) { + return () -> getRestTemplate() + .map(t -> this.getExamineeById(t, id)) + .getOrThrow(); } @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()); + } + + 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()); + } + + 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()); + } + @Override public Result getSEBClientRestriction(final Exam exam) { - @SuppressWarnings("unused") - final String quizId = 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"); - }); + return getRestTemplate() + .map(t -> this.getRestrictionForAssignmentId(t, exam.externalId)); } @Override public Result applySEBClientRestriction( final String externalExamId, final SEBRestriction sebRestrictionData) { - - return Result.tryCatch(() -> { - - // 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"); - }); + return getRestTemplate() + .map(t -> this.setRestrictionForAssignmentId(t, externalExamId, sebRestrictionData)); } @Override public Result releaseSEBClientRestriction(final Exam exam) { - @SuppressWarnings("unused") - final String quizId = exam.externalId; + return getRestTemplate() + .map(t -> this.deleteRestrictionForAssignmentId(t, exam.externalId)) + .map(x -> exam); + } + private T apiGet(final RestTemplate restTemplate, final String url, final Class type) { + final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup(); + final ResponseEntity res = restTemplate.exchange( + lmsSetup.lmsApiUrl + url, + HttpMethod.GET, + HttpEntity.EMPTY, + type); + return res.getBody(); + } + + private List apiGetList(final RestTemplate restTemplate, final String url, + final ParameterizedTypeReference> type) { + final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup(); + final ResponseEntity> res = restTemplate.exchange( + lmsSetup.lmsApiUrl + url, + HttpMethod.GET, + HttpEntity.EMPTY, + type); + return res.getBody(); + } + + private R apiPost(final RestTemplate restTemplate, final String url, final P post, final Class

postType, + final Class responseType) { + final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup(); + final HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.set("content-type", "application/json"); + final HttpEntity

requestEntity = new HttpEntity<>(post, httpHeaders); + final ResponseEntity res = restTemplate.exchange( + lmsSetup.lmsApiUrl + url, + HttpMethod.POST, + requestEntity, + responseType); + return res.getBody(); + } + + private T apiDelete(final RestTemplate restTemplate, final String url, final Class type) { + final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup(); + final ResponseEntity res = restTemplate.exchange( + lmsSetup.lmsApiUrl + url, + HttpMethod.DELETE, + HttpEntity.EMPTY, + type); + return res.getBody(); + } + + private Result getRestTemplate() { return Result.tryCatch(() -> { + if (this.cachedRestTemplate != null) { + return this.cachedRestTemplate; + } - // TODO Release respectively delete all SEB client restrictions for the given - // course / quize on the remote LMS. + final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup(); + 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; - } - } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/olat/OlatLmsData.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/olat/OlatLmsData.java new file mode 100644 index 00000000..7aab3e28 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/olat/OlatLmsData.java @@ -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 browserExamKeys; + public List configKeys; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + static final class RestrictionDataPost { + /* + * OLAT API example: + * { + * "configKeys": ["a", "b"], + * "browserExamKeys": ["1", "2"] + * } + */ + public List browserExamKeys; + public List configKeys; + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/olat/OlatLmsRestTemplate.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/olat/OlatLmsRestTemplate.java new file mode 100644 index 00000000..6c326124 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/olat/OlatLmsRestTemplate.java @@ -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 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; + } + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/JitsiProctoringService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/JitsiProctoringService.java index 3adb9627..df4b3fc8 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/JitsiProctoringService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/JitsiProctoringService.java @@ -378,6 +378,7 @@ public class JitsiProctoringService implements ExamProctoringService { null, null, null, + null, clientName); }); } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ZoomProctoringService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ZoomProctoringService.java index 20e38042..836bcc91 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ZoomProctoringService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ZoomProctoringService.java @@ -214,6 +214,9 @@ public class ZoomProctoringService implements ExamProctoringService { attributes.put( ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.ZOOM_TOKEN, String.valueOf(proctoringConnection.accessToken)); + attributes.put( + ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.ZOOM_SDK_TOKEN, + String.valueOf(proctoringConnection.sdkToken)); if (StringUtils.isNotBlank(proctoringConnection.apiKey)) { attributes.put( ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.ZOOM_API_KEY, @@ -262,6 +265,19 @@ public class ZoomProctoringService implements ExamProctoringService { String.valueOf(additionalZoomRoomData.meeting_id), 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( ProctoringServerType.ZOOM, null, @@ -270,6 +286,7 @@ public class ZoomProctoringService implements ExamProctoringService { roomName, subject, jwt, + sdkJWT, credentials.accessToken, credentials.clientId, String.valueOf(additionalZoomRoomData.meeting_id), @@ -308,6 +325,19 @@ public class ZoomProctoringService implements ExamProctoringService { .getConnectionData(connectionToken) .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( ProctoringServerType.ZOOM, connectionToken, @@ -316,6 +346,7 @@ public class ZoomProctoringService implements ExamProctoringService { roomName, subject, jwt, + sdkJWT, credentials.accessToken, credentials.clientId, 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( final ClientCredentials credentials, final String meetingId, diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 7982d4a7..abe74ff8 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -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.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.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.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.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.TOWN_HALL=Town-Hall Room sebserver.exam.proctoring.form.features.ONE_TO_ONE=One to One Room diff --git a/src/test/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/JitsiWindowScriptResolverTest.java b/src/test/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/JitsiWindowScriptResolverTest.java index bda05e45..749c24a4 100644 --- a/src/test/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/JitsiWindowScriptResolverTest.java +++ b/src/test/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/JitsiWindowScriptResolverTest.java @@ -37,6 +37,7 @@ public class JitsiWindowScriptResolverTest { "ROOM", "SUBJECT", "ACCESS_TOKEN", + null, "API_KEY", "ROOM_KEY", "MEETING_ID", @@ -53,6 +54,7 @@ public class JitsiWindowScriptResolverTest { "ROOM", "SUBJECT", "ACCESS_TOKEN", + null, "API_KEY", "ROOM_KEY", "MEETING_ID", diff --git a/src/test/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/ZoomWindowScriptResolverTest.java b/src/test/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/ZoomWindowScriptResolverTest.java index 6ef964a0..79118682 100644 --- a/src/test/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/ZoomWindowScriptResolverTest.java +++ b/src/test/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/ZoomWindowScriptResolverTest.java @@ -37,6 +37,7 @@ public class ZoomWindowScriptResolverTest { "ROOM", "SUBJECT", "ACCESS_TOKEN", + "SDK_TOKEN", "API_KEY", "ROOM_KEY", "MEETING_ID", @@ -53,6 +54,7 @@ public class ZoomWindowScriptResolverTest { "ROOM", "SUBJECT", "ACCESS_TOKEN", + "SDK_TOKEN", "API_KEY", "ROOM_KEY", "MEETING_ID", diff --git a/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/ExamProctoringRoomServiceTest.java b/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/ExamProctoringRoomServiceTest.java index ca0e9cee..282352c5 100644 --- a/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/ExamProctoringRoomServiceTest.java +++ b/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/ExamProctoringRoomServiceTest.java @@ -63,7 +63,7 @@ public class ExamProctoringRoomServiceTest extends AdministrationAPIIntegrationT 2L, new ProctoringServiceSettings( 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()); }