Merge pull request #30 from sealexan/dev-lms-open-olat

Implement OlatLms integration
This commit is contained in:
Andreas Hefti 2021-08-12 10:51:34 +02:00 committed by GitHub
commit c0c63f021e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 374 additions and 123 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,23 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.lms.APITemplateDataSupplier
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPIService; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPIService;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPITemplate; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPITemplate;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.AbstractCachedCourseAccess; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.AbstractCachedCourseAccess;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.olat.OlatLmsData.AssessmentData;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.olat.OlatLmsData.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;
private OlatLmsRestTemplate cachedRestTemplate;
protected OlatLmsAPITemplate( protected OlatLmsAPITemplate(
// TODO if you need more dependencies inject them here and set the reference
final ClientHttpRequestFactoryService clientHttpRequestFactoryService, final ClientHttpRequestFactoryService clientHttpRequestFactoryService,
final ClientCredentialService clientCredentialService, final ClientCredentialService clientCredentialService,
final APITemplateDataSupplier apiTemplateDataSupplier, final APITemplateDataSupplier apiTemplateDataSupplier,
@ -95,32 +113,20 @@ public class OlatLmsAPITemplate extends AbstractCachedCourseAccess implements Lm
final LmsSetupTestResult testLmsSetupSettings = testLmsSetupSettings(); final LmsSetupTestResult testLmsSetupSettings = testLmsSetupSettings();
if (testLmsSetupSettings.hasAnyError()) { if (testLmsSetupSettings.hasAnyError()) {
return testLmsSetupSettings; return testLmsSetupSettings;
} else {
} }
try {
// TODO check if the course API of the remote LMS is available this.getRestTemplate().get();
// if not, create corresponding LmsSetupTestResult error }
return LmsSetupTestResult.ofQuizAccessAPIError(LmsType.OPEN_OLAT, "TODO: implement LMS access check"); catch (Exception e) {
log.error("Failed to access OLAT course API: ", e);
//return LmsSetupTestResult.ofOkay(LmsType.OPEN_OLAT); return LmsSetupTestResult.ofQuizAccessAPIError(LmsType.OPEN_OLAT, e.getMessage());
}
return LmsSetupTestResult.ofOkay(LmsType.OPEN_OLAT);
} }
@Override @Override
public LmsSetupTestResult testCourseRestrictionAPI() { public LmsSetupTestResult testCourseRestrictionAPI() {
final LmsSetupTestResult testLmsSetupSettings = testLmsSetupSettings(); return testCourseAccessAPI();
if (testLmsSetupSettings.hasAnyError()) {
return testLmsSetupSettings;
}
if (LmsType.OPEN_OLAT.features.contains(Features.SEB_RESTRICTION)) {
// TODO check if the course API of the remote LMS is available
// if not, create corresponding LmsSetupTestResult error
}
return LmsSetupTestResult.ofOkay(LmsType.OPEN_OLAT);
} }
private LmsSetupTestResult testLmsSetupSettings() { private LmsSetupTestResult testLmsSetupSettings() {
@ -207,62 +213,95 @@ 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 olatRepositoryId) {
final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
return lmsSetup.lmsApiUrl + "/auth/RepositoryEntry/" + olatRepositoryId;
}
private List<QuizData> collectAllQuizzes(final OlatLmsRestTemplate restTemplate, final FilterMap filterMap) {
final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
final String quizName = filterMap.getString(QuizData.FILTER_ATTR_QUIZ_NAME);
final DateTime quizFromTime = (filterMap != null) ? filterMap.getQuizFromTime() : null;
final long fromCutTime = (quizFromTime != null) ? Utils.toUnixTimeInSeconds(quizFromTime) : -1;
String url = "/restapi/assessment_modes/seb?";
if (fromCutTime != -1) { url = String.format("%sdateFrom=%s&", url, fromCutTime); }
if (quizName != null) { url = String.format("%sname=%s&", url, quizName); }
final List<AssessmentData> as = this.apiGetList(restTemplate, url, new ParameterizedTypeReference<List<AssessmentData>>(){});
return as.stream()
.map(a -> {
return new QuizData(
String.format("%d", a.key),
lmsSetup.getInstitutionId(),
lmsSetup.id,
lmsSetup.getLmsType(),
a.name,
a.description,
Utils.toDateTimeUTC(a.dateFrom),
Utils.toDateTimeUTC(a.dateTo),
examUrl(a.repositoryEntryKey),
new HashMap<String, String>());})
.collect(Collectors.toList());
}
@Override @Override
protected Supplier<Collection<QuizData>> quizzesSupplier(final Set<String> ids) { protected Supplier<Collection<QuizData>> quizzesSupplier(final Set<String> ids) {
return () -> ids.stream().map(id -> quizSupplier(id).get()).collect(Collectors.toList());
return () -> {
// TODO get all quiz / course data for specified identifiers from remote LMS
// Transform the data from courses / quizzes from LMS into QuizData objects
// and put it to the cache: super.putToCache(quizDataCollection);
// before returning it.
throw new RuntimeException("TODO");
};
} }
@Override @Override
protected Supplier<QuizData> quizSupplier(final String id) { protected Supplier<QuizData> quizSupplier(final String id) {
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.repositoryEntryKey),
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,
"OLAT API does not provide email addresses",
attrs);
}
@Override @Override
protected Supplier<ExamineeAccountDetails> accountDetailsSupplier(final String examineeSessionId) { protected Supplier<ExamineeAccountDetails> accountDetailsSupplier(final String id) {
return () -> getRestTemplate()
return () -> { .map(t -> this.getExamineeById(t, id))
.getOrThrow();
// TODO get the examinee's account details by the given examineeSessionId from remote LMS.
// Currently only the name is needed to display on monitoring view.
throw new RuntimeException("TODO");
};
} }
@Override @Override
@ -272,55 +311,97 @@ 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 getRestTemplate()
final String quizId = exam.externalId; .map(t -> this.getRestrictionForAssignmentId(t, exam.externalId));
return Result.tryCatch(() -> {
// TODO get the SEB client restrictions that are currently set on the remote LMS for
// the given quiz / course derived from the given exam
throw new RuntimeException("TODO");
});
} }
@Override @Override
public Result<SEBRestriction> applySEBClientRestriction( public Result<SEBRestriction> applySEBClientRestriction(
final String externalExamId, final String externalExamId,
final SEBRestriction sebRestrictionData) { final SEBRestriction sebRestrictionData) {
return getRestTemplate()
return Result.tryCatch(() -> { .map(t -> this.setRestrictionForAssignmentId(t, externalExamId, sebRestrictionData));
// TODO apply the given sebRestrictionData settings as current SEB client restriction setting
// to the remote LMS for the given quiz / course.
// Mainly SEBRestriction.configKeys and SEBRestriction.browserExamKeys
throw new RuntimeException("TODO");
});
} }
@Override @Override
public Result<Exam> releaseSEBClientRestriction(final Exam exam) { public Result<Exam> releaseSEBClientRestriction(final Exam exam) {
@SuppressWarnings("unused")
final String quizId = exam.externalId; final String quizId = exam.externalId;
return getRestTemplate()
return Result.tryCatch(() -> { .map(t -> this.deleteRestrictionForAssignmentId(t, exam.externalId))
.map(x -> exam);
// TODO Release respectively delete all SEB client restrictions for the given
// course / quize on the remote LMS.
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() {
return Result.tryCatch(() -> {
if (this.cachedRestTemplate != null) { return this.cachedRestTemplate; }
final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup(); final LmsSetup lmsSetup = this.apiTemplateDataSupplier.getLmsSetup();
final ClientCredentials credentials = this.apiTemplateDataSupplier.getLmsClientCredentials(); final ClientCredentials credentials = this.apiTemplateDataSupplier.getLmsClientCredentials();
@ -332,7 +413,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 +421,12 @@ 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; this.cachedRestTemplate = template;
return this.cachedRestTemplate;
});
} }
} }

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2021 ETH Zürich, Educational Development and Technology (LET)
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.olat;
import java.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<String> browserExamKeys;
public List<String> configKeys;
}
@JsonIgnoreProperties(ignoreUnknown = true)
static final class RestrictionDataPost {
/* OLAT API example:
{
"configKeys": ["a", "b"],
"browserExamKeys": ["1", "2"]
}
*/
public List<String> browserExamKeys;
public List<String> configKeys;
}
}

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2021 ETH Zürich, Educational Development and Technology (LET)
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.olat;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.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<String> 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;
}
}
}