Implement OlatLms integration

This commit is contained in:
Carol Alexandru 2021-07-28 15:21:22 +02:00
parent 03036ec8ee
commit cdd393aecd
3 changed files with 332 additions and 95 deletions

View file

@ -10,19 +10,33 @@ 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.Collections;
import java.util.HashSet; import java.util.HashSet;
import java.util.Map;
import java.util.HashMap;
import java.util.List; import java.util.List;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime; import org.joda.time.DateTime;
import org.springframework.cache.CacheManager; 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.core.env.Environment;
import org.springframework.http.client.ClientHttpRequestFactory; 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.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.ClientHttpRequestFactoryService;
import ch.ethz.seb.sebserver.gbl.api.APIMessage; 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.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.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 { 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;
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 +111,33 @@ 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 {
} }
// TODO: improve error handling
// TODO check if the course API of the remote LMS is available try {
// if not, create corresponding LmsSetupTestResult error final RestTemplate restTemplate = this.getRestTemplate().get();
return LmsSetupTestResult.ofQuizAccessAPIError(LmsType.OPEN_OLAT, "TODO: implement LMS access check"); }
catch (Exception e) {
//return LmsSetupTestResult.ofOkay(LmsType.OPEN_OLAT); return LmsSetupTestResult.ofQuizAccessAPIError(LmsType.OPEN_OLAT, "Unspecific error connecting to OLAT API");
}
return LmsSetupTestResult.ofOkay(LmsType.OPEN_OLAT);
} }
@Override @Override
public LmsSetupTestResult testCourseRestrictionAPI() { 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()) { if (testLmsSetupSettings.hasAnyError()) {
return testLmsSetupSettings; return testLmsSetupSettings;
} }
if (LmsType.OPEN_OLAT.features.contains(Features.SEB_RESTRICTION)) { 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 LmsSetupTestResult.ofOkay(LmsType.OPEN_OLAT);
*/
} }
private LmsSetupTestResult testLmsSetupSettings() { private LmsSetupTestResult testLmsSetupSettings() {
@ -207,62 +224,96 @@ 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 () -> {
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(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<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 -> 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<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) {
final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
return () -> { return () -> getRestTemplate()
.map(t -> this.quizById(t, id))
// TODO get the specified quiz / course data for specified identifier from remote LMS .getOrThrow();
// and put it to the cache: super.putToCache(quizDataCollection);
// before returning it.
throw new RuntimeException("TODO");
};
} }
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<String, String>());
}
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<String, String> 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 @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,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<String, String>());
}
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<String, String>());
}
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<String, String>());
}
@Override @Override
public Result<SEBRestriction> getSEBClientRestriction(final Exam exam) { public Result<SEBRestriction> getSEBClientRestriction(final Exam exam) {
@SuppressWarnings("unused") return Result.of(getRestTemplate()
final String quizId = exam.externalId; .map(t -> this.getRestrictionForAssignmentId(t, exam.externalId))
.getOrThrow());
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 Result.of(getRestTemplate()
return Result.tryCatch(() -> { .map(t -> this.setRestrictionForAssignmentId(t, externalExamId, sebRestrictionData))
.getOrThrow());
// 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
@ -306,22 +367,57 @@ public class OlatLmsAPITemplate extends AbstractCachedCourseAccess implements Lm
@SuppressWarnings("unused") @SuppressWarnings("unused")
final String quizId = exam.externalId; final String quizId = exam.externalId;
return Result.tryCatch(() -> { return Result.of(getRestTemplate()
.map(t -> this.deleteRestrictionForAssignmentId(t, exam.externalId))
// TODO Release respectively delete all SEB client restrictions for the given .map(x -> exam)
// course / quize on the remote LMS. .getOrThrow());
throw new RuntimeException("TODO");
});
} }
// TODO: This is an example of how to create a RestTemplate for the service to access the LMS API private <T> T apiGet(final RestTemplate restTemplate, String url, Class<T> type) {
// The example deals with a Http based API that is secured by an OAuth2 client-credential flow. final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
// You might need some different template, then you have to adapt this code final ResponseEntity<T> res = restTemplate.exchange(
// To your needs. lmsSetup.lmsApiUrl + url,
@SuppressWarnings("unused") HttpMethod.GET,
private OAuth2RestTemplate createRestTemplate(final String accessTokenRequestPath) { HttpEntity.EMPTY,
type);
return res.getBody();
}
private <T> List<T> apiGetList(final RestTemplate restTemplate, String url, 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, String url, P post, Class<P> postType, Class<R> responseType) {
final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
final HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.set("content-type", "application/json");
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, String url, 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() {
// TODO: cache/reuse authenticated template for more than 1 request?
final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup(); final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
final ClientCredentials credentials = this.apiTemplateDataSupplier.getLmsClientCredentials(); final ClientCredentials credentials = this.apiTemplateDataSupplier.getLmsClientCredentials();
final ProxyData proxyData = this.apiTemplateDataSupplier.getProxyData(); final ProxyData proxyData = this.apiTemplateDataSupplier.getProxyData();
@ -332,7 +428,7 @@ public class OlatLmsAPITemplate extends AbstractCachedCourseAccess implements Lm
.getOrThrow(); .getOrThrow();
final ClientCredentialsResourceDetails details = new ClientCredentialsResourceDetails(); final ClientCredentialsResourceDetails details = new ClientCredentialsResourceDetails();
details.setAccessTokenUri(lmsSetup.lmsApiUrl + accessTokenRequestPath); details.setAccessTokenUri(lmsSetup.lmsApiUrl + "/restapi/auth/");
details.setClientId(plainClientId.toString()); details.setClientId(plainClientId.toString());
details.setClientSecret(plainClientSecret.toString()); details.setClientSecret(plainClientSecret.toString());
@ -340,10 +436,10 @@ public class OlatLmsAPITemplate extends AbstractCachedCourseAccess implements Lm
.getClientHttpRequestFactory(proxyData) .getClientHttpRequestFactory(proxyData)
.getOrThrow(); .getOrThrow();
final OAuth2RestTemplate template = new OAuth2RestTemplate(details); final OlatLmsRestTemplate template = new OlatLmsRestTemplate(details);
template.setRequestFactory(clientHttpRequestFactory); template.setRequestFactory(clientHttpRequestFactory);
return template; return Result.of(template);
} }
} }

View file

@ -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<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,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<String> 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);
}
});
}
}