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..9f7dff08 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/olat/OlatLmsData.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.util.List; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +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..f270a648 --- /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.web.client.RestTemplate; +import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsResourceDetails; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +public class OlatLmsRestTemplate extends RestTemplate { + + private static final Logger log = LoggerFactory.getLogger(OlatLmsRestTemplate.class); + + private String token; + private ClientCredentialsResourceDetails details; + + public OlatLmsRestTemplate(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(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { + // if there's no token, authenticate first + if (token == null) { authenticate(); } + // when authenticating, just do a normal call + else if (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", 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", 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 + token = "authenticating"; + final String authUrl = String.format("%s%s?password=%s", + details.getAccessTokenUri(), + details.getClientId(), + details.getClientSecret()); + try { + final ResponseEntity response = this.getForEntity(authUrl, String.class); + final HttpHeaders responseHeaders = response.getHeaders(); + log.debug("OLAT [authenticate] {} Headers: {}", response.getStatusCode(), responseHeaders); + token = responseHeaders.getFirst("X-OLAT-TOKEN"); + } + catch (Exception e) { + token = null; + throw e; + } + } + + +} +