From cdd393aecdfc9111c0690a9bde68300473dff2fb Mon Sep 17 00:00:00 2001 From: Carol Alexandru Date: Wed, 28 Jul 2021 15:21:22 +0200 Subject: [PATCH] Implement OlatLms integration --- .../lms/impl/olat/OlatLmsAPITemplate.java | 286 ++++++++++++------ .../lms/impl/olat/OlatLmsData.java | 82 +++++ .../lms/impl/olat/OlatLmsRestTemplate.java | 59 ++++ 3 files changed, 332 insertions(+), 95 deletions(-) create mode 100644 src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/olat/OlatLmsData.java create mode 100644 src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/olat/OlatLmsRestTemplate.java 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..98d1da1e 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,33 @@ package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.olat; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashSet; +import java.util.Map; +import java.util.HashMap; import java.util.List; import java.util.Set; import java.util.function.Supplier; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import org.apache.commons.lang3.StringUtils; import org.joda.time.DateTime; import org.springframework.cache.CacheManager; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; import org.springframework.core.env.Environment; import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.security.oauth2.client.OAuth2RestTemplate; +import org.springframework.web.client.RestTemplate; import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsResourceDetails; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.util.DefaultUriBuilderFactory; +import org.springframework.web.util.DefaultUriBuilderFactory.EncodingMode; + import ch.ethz.seb.sebserver.ClientHttpRequestFactoryService; import ch.ethz.seb.sebserver.gbl.api.APIMessage; @@ -47,19 +61,21 @@ 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.UserData; +import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.olat.OlatLmsData.RestrictionData; +import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.olat.OlatLmsData.RestrictionDataPost; 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; 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 +111,33 @@ 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); + // TODO: improve error handling + try { + final RestTemplate restTemplate = this.getRestTemplate().get(); + } + catch (Exception e) { + return LmsSetupTestResult.ofQuizAccessAPIError(LmsType.OPEN_OLAT, "Unspecific error connecting to OLAT API"); + } + return LmsSetupTestResult.ofOkay(LmsType.OPEN_OLAT); } @Override public LmsSetupTestResult testCourseRestrictionAPI() { - final LmsSetupTestResult testLmsSetupSettings = testLmsSetupSettings(); + // TODO: Any reason to implement a separate check or is this good enough? + return testCourseAccessAPI(); + + /*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); + */ } private LmsSetupTestResult testLmsSetupSettings() { @@ -207,62 +224,96 @@ 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"); + List res = getRestTemplate() + .map(t -> this.collectAllQuizzes(t, filterMap)) + .getOrThrow(); + super.putToCache(res); + return res; }; } + private String examUrl(long olatTestId) { + final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup(); + // TODO: at the moment, we don't know olatTestId because we get the assessment mode id (a.key), not the test id. + return lmsSetup.lmsApiUrl + "/auth/RepositoryEntry/" + olatTestId; + } + + 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 -> 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.key), + 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 () -> { - - // 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"); - }; + final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup(); + return () -> getRestTemplate() + .map(t -> this.quizById(t, id)) + .getOrThrow(); } + 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.key), + new HashMap()); + } + + 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, + // TODO: other placeholder value? null? + "OLAT 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,33 +323,43 @@ public class OlatLmsAPITemplate extends AbstractCachedCourseAccess implements Lm }; } + private SEBRestriction getRestrictionForAssignmentId(final RestTemplate restTemplate, 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, String id, 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, 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 Result.of(getRestTemplate() + .map(t -> this.getRestrictionForAssignmentId(t, exam.externalId)) + .getOrThrow()); } @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 Result.of(getRestTemplate() + .map(t -> this.setRestrictionForAssignmentId(t, externalExamId, sebRestrictionData)) + .getOrThrow()); } @Override @@ -306,22 +367,57 @@ public class OlatLmsAPITemplate extends AbstractCachedCourseAccess implements Lm @SuppressWarnings("unused") final String quizId = exam.externalId; - return Result.tryCatch(() -> { - - // TODO Release respectively delete all SEB client restrictions for the given - // course / quize on the remote LMS. - - throw new RuntimeException("TODO"); - }); + return Result.of(getRestTemplate() + .map(t -> this.deleteRestrictionForAssignmentId(t, exam.externalId)) + .map(x -> exam) + .getOrThrow()); } - // 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) { + private T apiGet(final RestTemplate restTemplate, String url, 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, String url, 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, String url, P post, Class

postType, Class responseType) { + final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup(); + final HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.set("content-type", "application/json"); + 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, String url, 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() { + // TODO: cache/reuse authenticated template for more than 1 request? final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup(); final ClientCredentials credentials = this.apiTemplateDataSupplier.getLmsClientCredentials(); final ProxyData proxyData = this.apiTemplateDataSupplier.getProxyData(); @@ -332,7 +428,7 @@ public class OlatLmsAPITemplate extends AbstractCachedCourseAccess implements Lm .getOrThrow(); final ClientCredentialsResourceDetails details = new ClientCredentialsResourceDetails(); - details.setAccessTokenUri(lmsSetup.lmsApiUrl + accessTokenRequestPath); + details.setAccessTokenUri(lmsSetup.lmsApiUrl + "/restapi/auth/"); details.setClientId(plainClientId.toString()); details.setClientSecret(plainClientSecret.toString()); @@ -340,10 +436,10 @@ public class OlatLmsAPITemplate extends AbstractCachedCourseAccess implements Lm .getClientHttpRequestFactory(proxyData) .getOrThrow(); - final OAuth2RestTemplate template = new OAuth2RestTemplate(details); + final OlatLmsRestTemplate template = new OlatLmsRestTemplate(details); template.setRequestFactory(clientHttpRequestFactory); - return template; + return Result.of(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..9fe7b961 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/olat/OlatLmsData.java @@ -0,0 +1,82 @@ +/* + * 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: + { + "key":7405568, + "name":"Test1", + "description":"", + "courseName":"test", + "dateFrom":1626515100000, + "dateTo":1626523260000 + } + */ + public long key; + 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..65112dd6 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/olat/OlatLmsRestTemplate.java @@ -0,0 +1,59 @@ +/* + * 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.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); + + public String token; + + public OlatLmsRestTemplate(ClientCredentialsResourceDetails details) { + super(); + + // Authenticate with OLAT and store the received X-OLAT-TOKEN + final String authUrl = String.format("%s%s?password=%s", + details.getAccessTokenUri(), + details.getClientId(), + details.getClientSecret()); + final HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.set("accept", "application/json"); + ResponseEntity response = this.getForEntity(authUrl, String.class); + HttpHeaders responseHeaders = response.getHeaders(); + log.debug("OLAT Auth Response Headers: {}", responseHeaders); + token = responseHeaders.getFirst("X-OLAT-TOKEN"); + + // 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 { + request.getHeaders().set("X-OLAT-TOKEN", token); + request.getHeaders().set("accept", "application/json"); + HttpHeaders responseHeaders = response.getHeaders(); + return execution.execute(request, body); + } + }); + } +} +