SEBSERV-73 finished first feature complete

This commit is contained in:
anhefti 2019-11-11 16:39:48 +01:00
parent 0d4e5c419a
commit 6b48bca761
25 changed files with 1274 additions and 645 deletions

View file

@ -162,7 +162,7 @@ public final class Exam implements GrantEntity {
this.quitPassword = quitPassword;
this.owner = owner;
this.status = (status != null) ? status : getStatusFromDate(startTime, endTime);
this.lmsSebRestriction = (lmsSebRestriction != null) ? lmsSebRestriction : Boolean.TRUE;
this.lmsSebRestriction = (lmsSebRestriction != null) ? lmsSebRestriction : Boolean.FALSE;
this.browserExamKeys = browserExamKeys;
this.active = (active != null) ? active : Boolean.TRUE;
this.lastUpdate = lastUpdate;

View file

@ -0,0 +1,106 @@
/*
* Copyright (c) 2019 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.gbl.model.institution;
import java.util.Collection;
import org.apache.commons.lang3.BooleanUtils;
import com.fasterxml.jackson.annotation.JsonProperty;
import ch.ethz.seb.sebserver.gbl.util.Utils;
public final class EdxCourseRestrictionAttributes {
public enum PermissionComponents {
ALWAYS_ALLOW_STUFF("AlwaysAllowStaff"),
CHECK_BROWSER_EXAM_KEY("CheckSEBHashBrowserExamKey"),
CHECK_CONFIG_KEY("CheckSEBHashConfigKey"),
CHECK_BROWSER_EXAM_KEY_AND_CONFIG_KEY("CheckSEBHashBrowserExamKeyOrConfigKey");
public final String name;
private PermissionComponents(final String name) {
this.name = name;
}
}
public static final String ATTR_USER_BANNING_ENABLED = "userBanningEnabled";
public static final String ATTR_SEB_PERMISSION_COMPONENTS = "permissionComponents";
public static final String ATTR_BLACKLIST_CHAPTERS = "blacklistChapters";
public static final String ATTR_WHITELIST_PATHS = "whitelistPaths";
public static final String ATTR_BROWSER_EXAM_KEYS = "browserExamKeys";
@JsonProperty(ATTR_WHITELIST_PATHS)
public final Collection<String> whiteListPaths;
@JsonProperty(ATTR_BLACKLIST_CHAPTERS)
public final Collection<String> blacklistChapters;
@JsonProperty(ATTR_SEB_PERMISSION_COMPONENTS)
public final Collection<String> permissionComponents;
@JsonProperty(ATTR_USER_BANNING_ENABLED)
public final Boolean banningEnabled;
@JsonProperty(ATTR_BROWSER_EXAM_KEYS)
public final Collection<String> browserExamKeys;
protected EdxCourseRestrictionAttributes(
final Collection<String> whiteListPaths,
final Collection<String> blacklistChapters,
final Collection<String> permissionComponents,
final Boolean banningEnabled,
final Collection<String> browserExamKeys) {
this.whiteListPaths = Utils.immutableCollectionOf(whiteListPaths);
this.blacklistChapters = Utils.immutableCollectionOf(blacklistChapters);
this.permissionComponents = Utils.immutableCollectionOf(permissionComponents);
this.banningEnabled = BooleanUtils.isTrue(banningEnabled);
this.browserExamKeys = Utils.immutableCollectionOf(browserExamKeys);
}
public Collection<String> getWhiteListPaths() {
return this.whiteListPaths;
}
public Collection<String> getBlacklistChapters() {
return this.blacklistChapters;
}
public Collection<String> getPermissionComponents() {
return this.permissionComponents;
}
public Boolean getBanningEnabled() {
return this.banningEnabled;
}
public Collection<String> getBrowserExamKeys() {
return this.browserExamKeys;
}
@Override
public String toString() {
final StringBuilder builder = new StringBuilder();
builder.append("EdxCourseRestrictionAttributes [whiteListPaths=");
builder.append(this.whiteListPaths);
builder.append(", blacklistChapters=");
builder.append(this.blacklistChapters);
builder.append(", permissionComponents=");
builder.append(this.permissionComponents);
builder.append(", banningEnabled=");
builder.append(this.banningEnabled);
builder.append(", browserExamKeys=");
builder.append(this.browserExamKeys);
builder.append("]");
return builder.toString();
}
}

View file

@ -11,10 +11,8 @@ package ch.ethz.seb.sebserver.gbl.model.institution;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import javax.validation.constraints.NotNull;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
@ -23,94 +21,109 @@ import ch.ethz.seb.sebserver.gbl.util.Utils;
public final class LmsSetupTestResult {
public static final String ATTR_OK_STATUS = "okStatus";
public static final String ATTR_ERROR_TYPE = "errorType";
public static final String ATTR_ERROR_MESSAGE = "errorMessage";
public static final String ATTR_ERRORS = "errors";
public static final String ATTR_MISSING_ATTRIBUTE = "missingLMSSetupAttribute";
public static final String ATTR_ERROR_TOKEN_REQUEST = "tokenRequestError";
public static final String ATTR_ERROR_QUIZ_REQUEST = "quizRequestError";
@JsonProperty(ATTR_OK_STATUS)
@NotNull
public final Boolean okStatus;
public enum ErrorType {
MISSING_ATTRIBUTE,
TOKEN_REQUEST,
QUIZ_ACCESS_API_REQUEST,
QUIZ_RESTRICTION_API_REQUEST
}
@JsonProperty(ATTR_ERRORS)
public final Collection<Error> errors;
@JsonProperty(ATTR_MISSING_ATTRIBUTE)
public final List<APIMessage> missingLMSSetupAttribute;
@JsonProperty(ATTR_ERROR_TOKEN_REQUEST)
public final String tokenRequestError;
@JsonProperty(ATTR_ERROR_QUIZ_REQUEST)
public final String quizRequestError;
public final Collection<APIMessage> missingLMSSetupAttribute;
@JsonCreator
public LmsSetupTestResult(
@JsonProperty(value = ATTR_OK_STATUS, required = true) final Boolean ok,
@JsonProperty(ATTR_MISSING_ATTRIBUTE) final Collection<APIMessage> missingLMSSetupAttribute,
@JsonProperty(ATTR_ERROR_TOKEN_REQUEST) final String tokenRequestError,
@JsonProperty(ATTR_ERROR_QUIZ_REQUEST) final String quizRequestError) {
@JsonProperty(ATTR_ERRORS) final Collection<Error> errors,
@JsonProperty(ATTR_MISSING_ATTRIBUTE) final Collection<APIMessage> missingLMSSetupAttribute) {
this.okStatus = ok;
// TODO
this.missingLMSSetupAttribute = Utils.immutableListOf(missingLMSSetupAttribute);
this.tokenRequestError = tokenRequestError;
this.quizRequestError = quizRequestError;
this.errors = Utils.immutableCollectionOf(errors);
this.missingLMSSetupAttribute = Utils.immutableCollectionOf(missingLMSSetupAttribute);
}
protected LmsSetupTestResult() {
this(
Collections.emptyList(),
Collections.emptyList());
}
protected LmsSetupTestResult(final Error error) {
this(
Utils.immutableCollectionOf(Arrays.asList(error)),
Collections.emptyList());
}
protected LmsSetupTestResult(final Error error, final Collection<APIMessage> missingLMSSetupAttribute) {
this(
Utils.immutableCollectionOf(Arrays.asList(error)),
Utils.immutableCollectionOf(missingLMSSetupAttribute));
}
@JsonIgnore
public boolean isOk() {
return this.okStatus != null && this.okStatus.booleanValue();
return this.errors == null || this.errors.isEmpty();
}
public Boolean getOkStatus() {
return this.okStatus;
@JsonIgnore
public boolean isQuizAccessOk() {
return isOk() || hasError(ErrorType.QUIZ_RESTRICTION_API_REQUEST);
}
public List<APIMessage> getMissingLMSSetupAttribute() {
return this.missingLMSSetupAttribute;
}
public String getTokenRequestError() {
return this.tokenRequestError;
}
public String getQuizRequestError() {
return this.quizRequestError;
}
@Override
public String toString() {
final StringBuilder builder = new StringBuilder();
builder.append("LmsSetupTestResult [okStatus=");
builder.append(this.okStatus);
builder.append(", missingLMSSetupAttribute=");
builder.append(this.missingLMSSetupAttribute);
builder.append(", tokenRequestError=");
builder.append(this.tokenRequestError);
builder.append(", quizRequestError=");
builder.append(this.quizRequestError);
builder.append("]");
return builder.toString();
@JsonIgnore
public boolean hasError(final ErrorType type) {
return this.errors
.stream()
.filter(error -> error.errorType == type)
.findFirst()
.isPresent();
}
public static final LmsSetupTestResult ofOkay() {
return new LmsSetupTestResult(true, Collections.emptyList(), null, null);
return new LmsSetupTestResult();
}
public static final LmsSetupTestResult ofMissingAttributes(final Collection<APIMessage> attrs) {
return new LmsSetupTestResult(false, attrs, null, null);
return new LmsSetupTestResult(new Error(ErrorType.MISSING_ATTRIBUTE, "missing attribute(s)"), attrs);
}
public static final LmsSetupTestResult ofMissingAttributes(final APIMessage... attrs) {
if (attrs == null) {
return new LmsSetupTestResult(false, Collections.emptyList(), null, null);
}
return new LmsSetupTestResult(false, Arrays.asList(attrs), null, null);
return new LmsSetupTestResult(new Error(ErrorType.MISSING_ATTRIBUTE, "missing attribute(s)"),
Arrays.asList(attrs));
}
public static final LmsSetupTestResult ofTokenRequestError(final String message) {
return new LmsSetupTestResult(false, Collections.emptyList(), message, null);
return new LmsSetupTestResult(new Error(ErrorType.TOKEN_REQUEST, message));
}
public static final LmsSetupTestResult ofQuizRequestError(final String message) {
return new LmsSetupTestResult(false, Collections.emptyList(), null, message);
public static final LmsSetupTestResult ofQuizAccessAPIError(final String message) {
return new LmsSetupTestResult(new Error(ErrorType.QUIZ_ACCESS_API_REQUEST, message));
}
public static final LmsSetupTestResult ofQuizRestrictionAPIError(final String message) {
return new LmsSetupTestResult(new Error(ErrorType.QUIZ_RESTRICTION_API_REQUEST, message));
}
public final static class Error {
@JsonProperty(ATTR_ERROR_TYPE)
public final ErrorType errorType;
@JsonProperty(ATTR_ERROR_MESSAGE)
public final String message;
@JsonCreator
protected Error(
@JsonProperty(ATTR_ERROR_TYPE) final ErrorType errorType,
@JsonProperty(ATTR_ERROR_MESSAGE) final String message) {
this.errorType = errorType;
this.message = message;
}
}
}

View file

@ -40,6 +40,7 @@ import ch.ethz.seb.sebserver.gbl.model.exam.Exam.ExamStatus;
import ch.ethz.seb.sebserver.gbl.model.exam.ExamConfigurationMap;
import ch.ethz.seb.sebserver.gbl.model.exam.Indicator;
import ch.ethz.seb.sebserver.gbl.model.exam.QuizData;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult;
import ch.ethz.seb.sebserver.gbl.model.user.UserRole;
import ch.ethz.seb.sebserver.gbl.profile.GuiProfile;
import ch.ethz.seb.sebserver.gbl.util.Result;
@ -68,6 +69,7 @@ import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.exam.GetExamConfi
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.exam.GetIndicatorPage;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.exam.SaveExam;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.exam.SetExamSebRestriction;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.lmssetup.TestLmsSetup;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.quiz.GetQuizData;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.quiz.ImportAsExam;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.auth.CurrentUser;
@ -215,6 +217,7 @@ public class ExamForm implements TemplateComposer {
final boolean editable = examStatus == ExamStatus.UP_COMING
|| examStatus == ExamStatus.RUNNING
&& currentUser.get().hasRole(UserRole.EXAM_ADMIN);
final boolean sebRestrictionAvailable = testSebRestrictionAPI(exam);
// The Exam form
final FormHandle<Exam> formHandle = this.pageService.formBuilder(
@ -301,7 +304,9 @@ public class ExamForm implements TemplateComposer {
.publishIf(() -> modifyGrant && readonly && editable)
.newAction(ActionDefinition.EXAM_SAVE)
.withExec(formHandle::processFormSave)
.withExec(action -> (importFromQuizData)
? importExam(action, formHandle, sebRestrictionAvailable && exam.status == ExamStatus.RUNNING)
: formHandle.processFormSave(action))
.ignoreMoveAwayFromEdit()
.publishIf(() -> !readonly && modifyGrant)
@ -314,12 +319,12 @@ public class ExamForm implements TemplateComposer {
.newAction(ActionDefinition.EXAM_ENABLE_SEB_RESTRICTION)
.withEntityKey(entityKey)
.withExec(action -> setSebRestriction(action, true))
.publishIf(() -> readonly && BooleanUtils.isFalse(exam.lmsSebRestriction))
.publishIf(() -> sebRestrictionAvailable && readonly && BooleanUtils.isFalse(exam.lmsSebRestriction))
.newAction(ActionDefinition.EXAM_DISABLE_SEB_RESTRICTION)
.withEntityKey(entityKey)
.withExec(action -> setSebRestriction(action, false))
.publishIf(() -> readonly && BooleanUtils.isTrue(exam.lmsSebRestriction));
.publishIf(() -> sebRestrictionAvailable && readonly && BooleanUtils.isTrue(exam.lmsSebRestriction));
// additional data in read-only view
if (readonly && !importFromQuizData) {
@ -473,6 +478,31 @@ public class ExamForm implements TemplateComposer {
}
}
private PageAction importExam(
final PageAction action,
final FormHandle<Exam> formHandle,
final boolean applySebRestriction) {
// process normal save first
final PageAction processFormSave = formHandle.processFormSave(action);
// when okay and the exam sebRestriction is true
if (applySebRestriction) {
setSebRestriction(processFormSave, true);
}
return processFormSave;
}
private boolean testSebRestrictionAPI(final Exam exam) {
return this.restService.getBuilder(TestLmsSetup.class)
.withURIVariable(API.PARAM_MODEL_ID, String.valueOf(exam.lmsSetupId))
.call()
.onError(t -> log.error("Failed to check SEB restriction API: ", t))
.map(result -> !result.hasError(LmsSetupTestResult.ErrorType.QUIZ_RESTRICTION_API_REQUEST))
.getOr(false);
}
private void showConsistencyChecks(final Collection<APIMessage> result, final Composite parent) {
if (result == null || result.isEmpty()) {
return;
@ -506,7 +536,7 @@ public class ExamForm implements TemplateComposer {
this.restService.getBuilder(SetExamSebRestriction.class)
.withURIVariable(
API.PARAM_MODEL_ID,
action.pageContext().getAttribute(AttributeKeys.ENTITY_ID))
action.getEntityKey().modelId)
.withQueryParam(
Domain.EXAM.ATTR_LMS_SEB_RESTRICTION,
sebRestriction ? Constants.TRUE_STRING : Constants.FALSE_STRING)

View file

@ -11,7 +11,6 @@ package ch.ethz.seb.sebserver.gui.content;
import java.util.function.BooleanSupplier;
import java.util.function.Function;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.swt.widgets.Composite;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -42,6 +41,7 @@ import ch.ethz.seb.sebserver.gui.service.page.PageService;
import ch.ethz.seb.sebserver.gui.service.page.TemplateComposer;
import ch.ethz.seb.sebserver.gui.service.page.impl.PageAction;
import ch.ethz.seb.sebserver.gui.service.page.impl.PageUtils;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.RestCallError;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.RestService;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.institution.GetInstitution;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.lmssetup.ActivateLmsSetup;
@ -177,7 +177,7 @@ public class LmsSetupForm implements TemplateComposer {
.addField(FormBuilder.singleSelection(
Domain.LMS_SETUP.ATTR_LMS_TYPE,
FORM_TYPE_TEXT_KEY,
(lmsType != null) ? lmsType.name() : null,
(lmsType != null) ? lmsType.name() : LmsType.MOCKUP.name(),
this.resourceService::lmsTypeResources)
.readonlyIf(isNotNew))
.addField(FormBuilder.text(
@ -225,17 +225,11 @@ public class LmsSetupForm implements TemplateComposer {
.withEntityKey(entityKey)
.publishIf(() -> modifyGrant && readonly && institutionActive)
.newAction(ActionDefinition.LMS_SETUP_SAVE_AND_TEST)
.withEntityKey(entityKey)
.withExec(action -> this.testLmsSetup(action, formHandle, true))
.ignoreMoveAwayFromEdit()
.publishIf(() -> modifyGrant && isNotNew.getAsBoolean() && !readonly)
.newAction(ActionDefinition.LMS_SETUP_TEST_AND_SAVE)
.withEntityKey(entityKey)
.withExec(action -> this.testAdHoc(action, formHandle))
.ignoreMoveAwayFromEdit()
.publishIf(() -> modifyGrant && isNew.getAsBoolean() && !readonly)
.publishIf(() -> modifyGrant && !readonly)
.newAction(ActionDefinition.LMS_SETUP_DEACTIVATE)
.withEntityKey(entityKey)
@ -292,8 +286,12 @@ public class LmsSetupForm implements TemplateComposer {
// ... and handle the response
if (result.hasError()) {
if (formHandle.handleError(result.getError())) {
throw new PageMessageException(
new LocTextKey("sebserver.lmssetup.action.test.missingParameter"));
final Throwable error = result.getError();
if (error instanceof RestCallError) {
throw (RestCallError) error;
} else {
throw new RuntimeException("Cause: ", error);
}
}
}
@ -355,17 +353,37 @@ public class LmsSetupForm implements TemplateComposer {
if (testResult.isOk()) {
return onOK.apply(action);
} else if (StringUtils.isNotBlank(testResult.tokenRequestError)) {
throw new PageMessageException(
new LocTextKey("sebserver.lmssetup.action.test.tokenRequestError",
testResult.tokenRequestError));
} else if (StringUtils.isNotBlank(testResult.quizRequestError)) {
throw new PageMessageException(
new LocTextKey("sebserver.lmssetup.action.test.quizRequestError", testResult.quizRequestError));
} else {
throw new PageMessageException(
new LocTextKey("sebserver.lmssetup.action.test.unknownError", testResult));
}
testResult.errors
.stream()
.findFirst()
.ifPresent(error -> {
switch (error.errorType) {
case TOKEN_REQUEST: {
throw new PageMessageException(new LocTextKey(
"sebserver.lmssetup.action.test.tokenRequestError",
error.message));
}
case QUIZ_ACCESS_API_REQUEST: {
throw new PageMessageException(new LocTextKey(
"sebserver.lmssetup.action.test.quizRequestError",
error.message));
}
case QUIZ_RESTRICTION_API_REQUEST: {
// NOTE: quiz restriction is not mandatory for functional LmsSetup
// so this error is ignored here
break;
}
default: {
throw new PageMessageException(new LocTextKey(
"sebserver.lmssetup.action.test.unknownError",
error.message));
}
}
});
return onOK.apply(action);
}
}

View file

@ -232,7 +232,7 @@ public enum ActionDefinition {
PageStateDefinitionImpl.EXAM_VIEW,
ActionCategory.FORM),
EXAM_DISABLE_SEB_RESTRICTION(
new LocTextKey("sebserver.exam.action.deasebrestriction.disable"),
new LocTextKey("sebserver.exam.action.sebrestriction.disable"),
ImageIcon.TOGGLE_ON,
PageStateDefinitionImpl.EXAM_VIEW,
ActionCategory.FORM),

View file

@ -113,6 +113,8 @@ public final class PageAction {
}
return Collections.emptySet();
} catch (final PageMessageException e) {
throw e;
} catch (final Exception e) {
log.error("Unexpected error while trying to get current selection: ", e);
throw new PageMessageException(

View file

@ -36,15 +36,15 @@ public final class ClientCredentials {
}
public boolean hasClientId() {
return this.clientId != null && this.clientId.length() >= 0;
return this.clientId != null && this.clientId.length() > 0;
}
public boolean hasSecret() {
return this.secret != null && this.secret.length() >= 0;
return this.secret != null && this.secret.length() > 0;
}
public boolean hasAccessToken() {
return this.accessToken != null && this.accessToken.length() >= 0;
return this.accessToken != null && this.accessToken.length() > 0;
}
public String clientIdAsString() {

View file

@ -46,11 +46,17 @@ public interface LmsAPIService {
* @return LmsAPITemplate for specified LmsSetup configuration */
Result<LmsAPITemplate> getLmsAPITemplate(String lmsSetupId);
/** use this to the the specified LmsAPITemplate.
*
* @param template the LmsAPITemplate
* @return LmsSetupTestResult containing list of errors if happened */
LmsSetupTestResult test(LmsAPITemplate template);
/** This can be used to test an LmsSetup connection parameter without saving or heaving
* an already persistent version of an LmsSetup.
*
* @param lmsSetup
* @return */
* @return LmsSetupTestResult containing list of errors if happened */
LmsSetupTestResult testAdHoc(LmsSetup lmsSetup);
/** Get a LmsAPITemplate for specified LmsSetup configuration by primary key

View file

@ -8,7 +8,6 @@
package ch.ethz.seb.sebserver.webservice.servicelayer.lms;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
@ -17,15 +16,12 @@ import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import ch.ethz.seb.sebserver.gbl.api.APIMessage;
import ch.ethz.seb.sebserver.gbl.api.EntityType;
import ch.ethz.seb.sebserver.gbl.model.Domain.LMS_SETUP;
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.institution.LmsSetup;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.webservice.servicelayer.client.ClientCredentials;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ResourceNotFoundException;
@ -43,12 +39,16 @@ public interface LmsAPITemplate {
* @return the underling LMSSetup configuration for this LmsAPITemplate */
LmsSetup lmsSetup();
/** Performs a test for the underling LmsSetup configuration and checks if the
* LMS and the core API of the LMS can be accessed or if there are some difficulties,
* missing configuration data or connection/authentication errors.
*
* @return LmsSetupTestResult instance with the test result report */
LmsSetupTestResult testLmsSetup();
// /** Performs a test for the underling LmsSetup configuration and checks if the
// * LMS and the core API of the LMS can be accessed or if there are some difficulties,
// * missing configuration data or connection/authentication errors.
// *
// * @return LmsSetupTestResult instance with the test result report */
// LmsSetupTestResult testLmsSetup();
LmsSetupTestResult testCourseAccessAPI();
LmsSetupTestResult testCourseRestrictionAPI();
/** Get a Result of an unsorted List of filtered QuizData from the LMS course/quiz API
*
@ -101,27 +101,4 @@ public interface LmsAPITemplate {
* @return Result refer to the given Exam if successful or to an error if not */
Result<Exam> releaseSebClientRestriction(Exam exam);
default List<APIMessage> attributeValidation(final ClientCredentials credentials) {
final LmsSetup lmsSetup = lmsSetup();
// validation of LmsSetup
final List<APIMessage> missingAttrs = new ArrayList<>();
if (StringUtils.isBlank(lmsSetup.lmsApiUrl)) {
missingAttrs.add(APIMessage.fieldValidationError(
LMS_SETUP.ATTR_LMS_URL,
"lmsSetup:lmsUrl:notNull"));
}
if (!credentials.hasClientId()) {
missingAttrs.add(APIMessage.fieldValidationError(
LMS_SETUP.ATTR_LMS_CLIENTNAME,
"lmsSetup:lmsClientname:notNull"));
}
if (!credentials.hasSecret()) {
missingAttrs.add(APIMessage.fieldValidationError(
LMS_SETUP.ATTR_LMS_CLIENTSECRET,
"lmsSetup:lmsClientsecret:notNull"));
}
return missingAttrs;
}
}

View file

@ -14,17 +14,13 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import ch.ethz.seb.sebserver.ClientHttpRequestFactoryService;
import ch.ethz.seb.sebserver.gbl.Constants;
import ch.ethz.seb.sebserver.gbl.async.AsyncService;
import ch.ethz.seb.sebserver.gbl.model.Page;
import ch.ethz.seb.sebserver.gbl.model.exam.QuizData;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup;
@ -38,6 +34,7 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.LmsSetupDAO;
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.edx.OpenEdxLmsAPITemplateFactory;
@Lazy
@Service
@ -46,32 +43,23 @@ public class LmsAPIServiceImpl implements LmsAPIService {
private static final Logger log = LoggerFactory.getLogger(LmsAPIServiceImpl.class);
private final AsyncService asyncService;
private final LmsSetupDAO lmsSetupDAO;
private final ClientCredentialService clientCredentialService;
private final ClientHttpRequestFactoryService clientHttpRequestFactoryService;
private final String[] openEdxAlternativeTokenRequestPaths;
private final WebserviceInfo webserviceInfo;
private final OpenEdxLmsAPITemplateFactory openEdxLmsAPITemplateFactory;
private final Map<CacheKey, LmsAPITemplate> cache = new ConcurrentHashMap<>();
public LmsAPIServiceImpl(
final AsyncService asyncService,
final OpenEdxLmsAPITemplateFactory openEdxLmsAPITemplateFactory,
final LmsSetupDAO lmsSetupDAO,
final ClientCredentialService clientCredentialService,
final ClientHttpRequestFactoryService clientHttpRequestFactoryService,
final WebserviceInfo webserviceInfo,
@Value("${sebserver.webservice.lms.openedx.api.token.request.paths}") final String alternativeTokenRequestPaths) {
final WebserviceInfo webserviceInfo) {
this.asyncService = asyncService;
this.openEdxLmsAPITemplateFactory = openEdxLmsAPITemplateFactory;
this.lmsSetupDAO = lmsSetupDAO;
this.clientCredentialService = clientCredentialService;
this.clientHttpRequestFactoryService = clientHttpRequestFactoryService;
this.webserviceInfo = webserviceInfo;
this.openEdxAlternativeTokenRequestPaths = (alternativeTokenRequestPaths != null)
? StringUtils.split(alternativeTokenRequestPaths, Constants.LIST_SEPARATOR)
: null;
}
/** Listen to LmsSetupChangeEvent to release an affected LmsAPITemplate from cache
@ -145,6 +133,16 @@ public class LmsAPIServiceImpl implements LmsAPIService {
.flatMap(this::getLmsAPITemplate);
}
@Override
public LmsSetupTestResult test(final LmsAPITemplate template) {
final LmsSetupTestResult testCourseAccessAPI = template.testCourseAccessAPI();
if (!testCourseAccessAPI.isOk()) {
return testCourseAccessAPI;
}
return template.testCourseRestrictionAPI();
}
@Override
public LmsSetupTestResult testAdHoc(final LmsSetup lmsSetup) {
final ClientCredentials lmsCredentials = this.clientCredentialService.encryptClientCredentials(
@ -152,7 +150,7 @@ public class LmsAPIServiceImpl implements LmsAPIService {
lmsSetup.lmsAuthSecret,
lmsSetup.lmsRestApiToken);
return createLmsSetupTemplate(lmsSetup, lmsCredentials).testLmsSetup();
return test(createLmsSetupTemplate(lmsSetup, lmsCredentials));
}
private Result<LmsAPITemplate> getLmsAPITemplate(final LmsSetup lmsSetup) {
@ -202,14 +200,10 @@ public class LmsAPIServiceImpl implements LmsAPIService {
credentials,
this.webserviceInfo);
case OPEN_EDX:
return new OpenEdxLmsAPITemplate(
this.asyncService,
lmsSetup,
credentials,
this.clientCredentialService,
this.clientHttpRequestFactoryService,
this.openEdxAlternativeTokenRequestPaths,
this.webserviceInfo);
return this.openEdxLmsAPITemplateFactory
.create(lmsSetup, credentials)
.getOrThrow();
default:
throw new UnsupportedOperationException("No support for LMS Type: " + lmsSetup.lmsType);
}
@ -251,4 +245,5 @@ public class LmsAPIServiceImpl implements LmsAPIService {
return true;
}
}
}

View file

@ -22,6 +22,7 @@ import org.slf4j.LoggerFactory;
import ch.ethz.seb.sebserver.gbl.Constants;
import ch.ethz.seb.sebserver.gbl.api.APIMessage;
import ch.ethz.seb.sebserver.gbl.model.Domain.LMS_SETUP;
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.institution.LmsSetup;
@ -94,12 +95,32 @@ final class MockupLmsAPITemplate implements LmsAPITemplate {
return this.lmsSetup;
}
@Override
public LmsSetupTestResult testLmsSetup() {
private List<APIMessage> checkAttributes() {
final List<APIMessage> missingAttrs = new ArrayList<>();
if (StringUtils.isBlank(this.lmsSetup.lmsApiUrl)) {
missingAttrs.add(APIMessage.fieldValidationError(
LMS_SETUP.ATTR_LMS_URL,
"lmsSetup:lmsUrl:notNull"));
}
if (!this.credentials.hasClientId()) {
missingAttrs.add(APIMessage.fieldValidationError(
LMS_SETUP.ATTR_LMS_CLIENTNAME,
"lmsSetup:lmsClientname:notNull"));
}
if (!this.credentials.hasSecret()) {
missingAttrs.add(APIMessage.fieldValidationError(
LMS_SETUP.ATTR_LMS_CLIENTSECRET,
"lmsSetup:lmsClientsecret:notNull"));
}
return missingAttrs;
}
@Override
public LmsSetupTestResult testCourseAccessAPI() {
log.info("Test Lms Binding for Mockup and LmsSetup: {}", this.lmsSetup);
final List<APIMessage> missingAttrs = attributeValidation(this.credentials);
final List<APIMessage> missingAttrs = checkAttributes();
if (!missingAttrs.isEmpty()) {
return LmsSetupTestResult.ofMissingAttributes(missingAttrs);
}
@ -111,6 +132,12 @@ final class MockupLmsAPITemplate implements LmsAPITemplate {
}
}
@Override
public LmsSetupTestResult testCourseRestrictionAPI() {
// TODO Auto-generated method stub
return LmsSetupTestResult.ofQuizRestrictionAPIError("unsupported");
}
@Override
public Result<List<QuizData>> getQuizzes(final FilterMap filterMap) {

View file

@ -1,477 +0,0 @@
/*
* Copyright (c) 2019 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;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequest;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.oauth2.client.OAuth2ClientContext;
import org.springframework.security.oauth2.client.OAuth2RequestAuthenticator;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.client.http.AccessTokenRequiredException;
import org.springframework.security.oauth2.client.resource.OAuth2AccessDeniedException;
import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails;
import org.springframework.security.oauth2.client.resource.UserRedirectRequiredException;
import org.springframework.security.oauth2.client.token.AccessTokenRequest;
import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsAccessTokenProvider;
import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsResourceDetails;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import ch.ethz.seb.sebserver.ClientHttpRequestFactoryService;
import ch.ethz.seb.sebserver.gbl.Constants;
import ch.ethz.seb.sebserver.gbl.api.APIMessage;
import ch.ethz.seb.sebserver.gbl.api.ProxyData;
import ch.ethz.seb.sebserver.gbl.api.ProxyData.ProxyAuthType;
import ch.ethz.seb.sebserver.gbl.async.AsyncService;
import ch.ethz.seb.sebserver.gbl.async.MemoizingCircuitBreaker;
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.institution.LmsSetup;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.webservice.WebserviceInfo;
import ch.ethz.seb.sebserver.webservice.servicelayer.client.ClientCredentialService;
import ch.ethz.seb.sebserver.webservice.servicelayer.client.ClientCredentials;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap;
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.SebRestrictionData;
/** Implements the LmsAPITemplate for Open edX LMS Course API access.
*
* See also: https://course-catalog-api-guide.readthedocs.io */
final class OpenEdxLmsAPITemplate implements LmsAPITemplate {
private static final Logger log = LoggerFactory.getLogger(OpenEdxLmsAPITemplate.class);
private static final String OPEN_EDX_DEFAULT_TOKEN_REQUEST_PATH = "/oauth2/access_token";
private static final String OPEN_EDX_DEFAULT_COURSE_ENDPOINT = "/api/courses/v1/courses/";
private static final String OPEN_EDX_DEFAULT_COURSE_START_URL_PREFIX = "/courses/";
private static final String OPEN_EDX_DEFAULT_COURSE_RESTRICTION_API_PATH =
"/seb-openedx/api/v1/course/%s/configuration/";
private final LmsSetup lmsSetup;
private final ClientCredentials credentials;
private final ClientHttpRequestFactoryService clientHttpRequestFactoryService;
private final ClientCredentialService clientCredentialService;
private final Set<String> knownTokenAccessPaths;
private final WebserviceInfo webserviceInfo;
private OAuth2RestTemplate restTemplate = null;
private final MemoizingCircuitBreaker<List<QuizData>> allQuizzesSupplier;
OpenEdxLmsAPITemplate(
final AsyncService asyncService,
final LmsSetup lmsSetup,
final ClientCredentials credentials,
final ClientCredentialService clientCredentialService,
final ClientHttpRequestFactoryService clientHttpRequestFactoryService,
final String[] alternativeTokenRequestPaths,
final WebserviceInfo webserviceInfo) {
this.lmsSetup = lmsSetup;
this.clientCredentialService = clientCredentialService;
this.credentials = credentials;
this.clientHttpRequestFactoryService = clientHttpRequestFactoryService;
this.webserviceInfo = webserviceInfo;
this.knownTokenAccessPaths = new HashSet<>();
this.knownTokenAccessPaths.add(OPEN_EDX_DEFAULT_TOKEN_REQUEST_PATH);
if (alternativeTokenRequestPaths != null) {
this.knownTokenAccessPaths.addAll(Arrays.asList(alternativeTokenRequestPaths));
}
this.allQuizzesSupplier = asyncService.createMemoizingCircuitBreaker(
allQuizzesSupplier(),
3,
Constants.MINUTE_IN_MILLIS,
Constants.MINUTE_IN_MILLIS,
true,
Constants.HOUR_IN_MILLIS);
}
@Override
public LmsSetup lmsSetup() {
return this.lmsSetup;
}
@Override
public LmsSetupTestResult testLmsSetup() {
log.info("Test Lms Binding for OpenEdX and LmsSetup: {}", this.lmsSetup);
final List<APIMessage> missingAttrs = attributeValidation(this.credentials);
if (!missingAttrs.isEmpty()) {
return LmsSetupTestResult.ofMissingAttributes(missingAttrs);
}
// request OAuth2 access token on OpenEdx API
initRestTemplateAndRequestAccessToken();
if (this.restTemplate == null) {
return LmsSetupTestResult.ofTokenRequestError(
"Failed to gain access token from OpenEdX Rest API:\n tried token endpoints: " +
this.knownTokenAccessPaths);
}
try {
this.getEdxPage(this.lmsSetup.lmsApiUrl + OPEN_EDX_DEFAULT_COURSE_ENDPOINT);
} catch (final RuntimeException e) {
if (this.restTemplate != null) {
this.restTemplate.setAuthenticator(new EdxOAuth2RequestAuthenticator());
}
try {
this.getEdxPage(this.lmsSetup.lmsApiUrl + OPEN_EDX_DEFAULT_COURSE_ENDPOINT);
} catch (final RuntimeException ee) {
return LmsSetupTestResult.ofQuizRequestError(ee.getMessage());
}
}
return LmsSetupTestResult.ofOkay();
}
@Override
public Result<List<QuizData>> getQuizzes(final FilterMap filterMap) {
return this.allQuizzesSupplier.get()
.map(LmsAPIService.quizzesFilterFunction(filterMap));
}
@Override
public Collection<Result<QuizData>> getQuizzes(final Set<String> ids) {
// TODO this can be improved in the future
return getQuizzes(new FilterMap())
.getOrElse(() -> Collections.emptyList())
.stream()
.filter(quiz -> ids.contains(quiz.id))
.map(quiz -> Result.of(quiz))
.collect(Collectors.toList());
}
@Override
public Result<SebRestrictionData> applySebClientRestriction(final SebRestrictionData sebRestrictionData) {
return Result.tryCatch(() -> {
if (log.isDebugEnabled()) {
log.debug("Apply SEB Client restriction: {}", sebRestrictionData);
}
// TODO
return sebRestrictionData;
});
}
@Override
public Result<SebRestrictionData> updateSebClientRestriction(final SebRestrictionData sebRestrictionData) {
return Result.tryCatch(() -> {
if (log.isDebugEnabled()) {
log.debug("Update SEB Client restriction: {}", sebRestrictionData);
}
// TODO
return sebRestrictionData;
});
}
@Override
public Result<Exam> releaseSebClientRestriction(final Exam exam) {
return Result.tryCatch(() -> {
if (log.isDebugEnabled()) {
log.debug("Release SEB Client restriction for Exam: {}", exam);
}
// TODO
return exam;
});
}
private Result<LmsSetup> initRestTemplateAndRequestAccessToken() {
return Result.tryCatch(() -> {
if (this.restTemplate != null) {
try {
this.restTemplate.getAccessToken();
return this.lmsSetup;
} catch (final Exception e) {
log.warn(
"Error while trying to get access token within already existing OAuth2RestTemplate instance. Try to create new one.",
e);
this.restTemplate = null;
}
}
log.info("Initialize Rest Template for OpenEdX API access. LmsSetup: {}", this.lmsSetup);
final Iterator<String> tokenAccessPaths = this.knownTokenAccessPaths.iterator();
while (tokenAccessPaths.hasNext()) {
final String accessTokenRequestPath = tokenAccessPaths.next();
try {
final OAuth2RestTemplate template = createRestTemplate(
this.lmsSetup,
this.credentials,
accessTokenRequestPath);
final OAuth2AccessToken accessToken = template.getAccessToken();
if (accessToken != null) {
this.restTemplate = template;
return this.lmsSetup;
}
} catch (final Exception e) {
log.info("Failed to request access token on access token request path: {}", accessTokenRequestPath,
e);
}
}
throw new IllegalArgumentException(
"Unable to establish OpenEdX API connection for lmsSetup: " + this.lmsSetup);
});
}
private OAuth2RestTemplate createRestTemplate(
final LmsSetup lmsSetup,
final ClientCredentials credentials,
final String accessTokenRequestPath) throws URISyntaxException {
final CharSequence plainClientId = credentials.clientId;
final CharSequence plainClientSecret = this.clientCredentialService.getPlainClientSecret(credentials);
final ClientCredentialsResourceDetails details = new ClientCredentialsResourceDetails();
details.setAccessTokenUri(lmsSetup.lmsApiUrl + accessTokenRequestPath);
details.setClientId(plainClientId.toString());
details.setClientSecret(plainClientSecret.toString());
ClientHttpRequestFactory clientHttpRequestFactory = null;
if (lmsSetup.proxyAuthType != ProxyAuthType.NONE) {
final ClientCredentials proxyCredentials = new ClientCredentials(
lmsSetup.proxyAuthUsername,
lmsSetup.proxyAuthSecret);
// TODO check where we have to encrypt/decrypt the secret internally
CharSequence proxySecretPlain;
try {
proxySecretPlain = this.clientCredentialService.getPlainClientSecret(proxyCredentials);
} catch (final Exception e) {
proxySecretPlain = lmsSetup.proxyAuthSecret;
}
final URI uri = new URI(lmsSetup.lmsApiUrl);
final ProxyData proxyData = new ProxyData(
lmsSetup.proxyAuthType,
uri.getHost(),
uri.getPort(),
proxyCredentials.clientId,
proxySecretPlain);
clientHttpRequestFactory = this.clientHttpRequestFactoryService
.getClientHttpRequestFactory(proxyData)
.getOrThrow();
} else {
clientHttpRequestFactory = this.clientHttpRequestFactoryService
.getClientHttpRequestFactory()
.getOrThrow();
}
final OAuth2RestTemplate template = new OAuth2RestTemplate(details);
template.setRequestFactory(clientHttpRequestFactory);
template.setAccessTokenProvider(new EdxClientCredentialsAccessTokenProvider());
return template;
}
private Supplier<List<QuizData>> allQuizzesSupplier() {
return () -> {
return initRestTemplateAndRequestAccessToken()
.map(this::collectAllQuizzes)
.getOrThrow();
};
}
private ArrayList<QuizData> collectAllQuizzes(final LmsSetup lmsSetup) {
final String externalStartURI = getExternalLMSServerAddress(lmsSetup);
return collectAllCourses(lmsSetup.lmsApiUrl + OPEN_EDX_DEFAULT_COURSE_ENDPOINT)
.stream()
.reduce(
new ArrayList<QuizData>(),
(list, courseData) -> {
list.add(quizDataOf(lmsSetup, courseData, externalStartURI));
return list;
},
(list1, list2) -> {
list1.addAll(list2);
return list1;
});
}
private String getExternalLMSServerAddress(final LmsSetup lmsSetup) {
final String externalAddressAlias = this.webserviceInfo.getExternalAddressAlias(lmsSetup.lmsApiUrl);
String _externalStartURI = lmsSetup.lmsApiUrl + OPEN_EDX_DEFAULT_COURSE_START_URL_PREFIX;
if (StringUtils.isNoneBlank(externalAddressAlias)) {
try {
final URL url = new URL(lmsSetup.lmsApiUrl);
final int port = url.getPort();
_externalStartURI = url.getProtocol() +
Constants.URL_ADDRESS_SEPARATOR + externalAddressAlias +
((port >= 0)
? Constants.URL_PORT_SEPARATOR + port
: StringUtils.EMPTY)
+ OPEN_EDX_DEFAULT_COURSE_START_URL_PREFIX;
if (log.isDebugEnabled()) {
log.debug("Use external address for course access: {}", _externalStartURI);
}
} catch (final Exception e) {
log.error("Failed to create external address from alias: ", e);
}
}
return _externalStartURI;
}
private List<CourseData> collectAllCourses(final String pageURI) {
final List<CourseData> collector = new ArrayList<>();
EdXPage page = getEdxPage(pageURI).getBody();
if (page != null) {
collector.addAll(page.results);
while (page != null && StringUtils.isNotBlank(page.next)) {
page = getEdxPage(page.next).getBody();
if (page != null) {
collector.addAll(page.results);
}
}
}
return collector;
}
private ResponseEntity<EdXPage> getEdxPage(final String pageURI) {
final HttpHeaders httpHeaders = new HttpHeaders();
return this.restTemplate.exchange(
pageURI,
HttpMethod.GET,
new HttpEntity<>(httpHeaders),
EdXPage.class);
}
private static QuizData quizDataOf(
final LmsSetup lmsSetup,
final CourseData courseData,
final String uriPrefix) {
final String startURI = uriPrefix + courseData.id;
final Map<String, String> additionalAttrs = new HashMap<>();
additionalAttrs.put("blocks_url", courseData.blocks_url);
return new QuizData(
courseData.id,
lmsSetup.getInstitutionId(),
lmsSetup.id,
lmsSetup.getLmsType(),
courseData.name,
courseData.short_description,
courseData.start,
courseData.end,
startURI);
}
/** Maps a OpenEdX course API course page */
static final class EdXPage {
public Integer count;
public String previous;
public Integer num_pages;
public String next;
public List<CourseData> results;
}
/** Maps the OpenEdX course API course data */
static final class CourseData {
public String id;
public String name;
public String short_description;
public String blocks_url;
public String start;
public String end;
}
/** A custom ClientCredentialsAccessTokenProvider that adapts the access token request to Open edX
* access token request protocol using a form-URL-encoded POST request according to:
* https://course-catalog-api-guide.readthedocs.io/en/latest/authentication/index.html#getting-an-access-token */
private class EdxClientCredentialsAccessTokenProvider extends ClientCredentialsAccessTokenProvider {
@Override
public OAuth2AccessToken obtainAccessToken(
final OAuth2ProtectedResourceDetails details,
final AccessTokenRequest request)
throws UserRedirectRequiredException,
AccessDeniedException,
OAuth2AccessDeniedException {
if (details instanceof ClientCredentialsResourceDetails) {
final ClientCredentialsResourceDetails resource = (ClientCredentialsResourceDetails) details;
final HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE);
final MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "client_credentials");
params.add("client_id", resource.getClientId());
params.add("client_secret", resource.getClientSecret());
final OAuth2AccessToken retrieveToken = retrieveToken(request, resource, params, headers);
return retrieveToken;
} else {
return super.obtainAccessToken(details, request);
}
}
}
private class EdxOAuth2RequestAuthenticator implements OAuth2RequestAuthenticator {
@Override
public void authenticate(
final OAuth2ProtectedResourceDetails resource,
final OAuth2ClientContext clientContext,
final ClientHttpRequest request) {
final OAuth2AccessToken accessToken = clientContext.getAccessToken();
if (accessToken == null) {
throw new AccessTokenRequiredException(resource);
}
request.getHeaders().set("Authorization", String.format("%s %s", "Bearer", accessToken.getValue()));
}
}
}

View file

@ -0,0 +1,263 @@
/*
* Copyright (c) 2019 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.edx;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.ClientHttpRequest;
import org.springframework.security.oauth2.client.OAuth2ClientContext;
import org.springframework.security.oauth2.client.OAuth2RequestAuthenticator;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.client.http.AccessTokenRequiredException;
import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import ch.ethz.seb.sebserver.gbl.Constants;
import ch.ethz.seb.sebserver.gbl.async.AsyncService;
import ch.ethz.seb.sebserver.gbl.async.MemoizingCircuitBreaker;
import ch.ethz.seb.sebserver.gbl.model.exam.QuizData;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.webservice.WebserviceInfo;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPIService;
/** Implements the LmsAPITemplate for Open edX LMS Course API access.
*
* See also: https://course-catalog-api-guide.readthedocs.io */
final class OpenEdxCourseAccess {
private static final Logger log = LoggerFactory.getLogger(OpenEdxCourseAccess.class);
private static final String OPEN_EDX_DEFAULT_COURSE_ENDPOINT = "/api/courses/v1/courses/";
private static final String OPEN_EDX_DEFAULT_COURSE_START_URL_PREFIX = "/courses/";
private final LmsSetup lmsSetup;
private final OpenEdxRestTemplateFactory openEdxRestTemplateFactory;
private final WebserviceInfo webserviceInfo;
private final MemoizingCircuitBreaker<List<QuizData>> allQuizzesSupplier;
private OAuth2RestTemplate restTemplate;
public OpenEdxCourseAccess(
final LmsSetup lmsSetup,
final OpenEdxRestTemplateFactory openEdxRestTemplateFactory,
final WebserviceInfo webserviceInfo,
final AsyncService asyncService) {
this.lmsSetup = lmsSetup;
this.openEdxRestTemplateFactory = openEdxRestTemplateFactory;
this.webserviceInfo = webserviceInfo;
this.allQuizzesSupplier = asyncService.createMemoizingCircuitBreaker(
allQuizzesSupplier(),
3,
Constants.MINUTE_IN_MILLIS,
Constants.MINUTE_IN_MILLIS,
true,
Constants.HOUR_IN_MILLIS);
}
LmsSetupTestResult initAPIAccess() {
final LmsSetupTestResult attributesCheck = this.openEdxRestTemplateFactory.test();
if (!attributesCheck.isOk()) {
return attributesCheck;
}
final Result<OAuth2RestTemplate> restTemplateRequest = getRestTemplate();
if (restTemplateRequest.hasError()) {
final String message = "Failed to gain access token from OpenEdX Rest API:\n tried token endpoints: " +
this.openEdxRestTemplateFactory.knownTokenAccessPaths;
log.error(message, restTemplateRequest.getError());
return LmsSetupTestResult.ofTokenRequestError(message);
}
final OAuth2RestTemplate restTemplate = restTemplateRequest.get();
try {
this.getEdxPage(this.lmsSetup.lmsApiUrl + OPEN_EDX_DEFAULT_COURSE_ENDPOINT, restTemplate);
} catch (final RuntimeException e) {
restTemplate.setAuthenticator(new EdxOAuth2RequestAuthenticator());
try {
this.getEdxPage(this.lmsSetup.lmsApiUrl + OPEN_EDX_DEFAULT_COURSE_ENDPOINT, restTemplate);
} catch (final RuntimeException ee) {
return LmsSetupTestResult.ofQuizAccessAPIError(ee.getMessage());
}
}
return LmsSetupTestResult.ofOkay();
}
Result<List<QuizData>> getQuizzes(final FilterMap filterMap) {
return this.allQuizzesSupplier.get()
.map(LmsAPIService.quizzesFilterFunction(filterMap));
}
private Supplier<List<QuizData>> allQuizzesSupplier() {
return () -> {
return getRestTemplate()
.map(this::collectAllQuizzes)
.getOrThrow();
};
}
private ArrayList<QuizData> collectAllQuizzes(final OAuth2RestTemplate restTemplate) {
final String externalStartURI = getExternalLMSServerAddress(this.lmsSetup);
return collectAllCourses(
this.lmsSetup.lmsApiUrl + OPEN_EDX_DEFAULT_COURSE_ENDPOINT,
restTemplate)
.stream()
.reduce(
new ArrayList<QuizData>(),
(list, courseData) -> {
list.add(quizDataOf(this.lmsSetup, courseData, externalStartURI));
return list;
},
(list1, list2) -> {
list1.addAll(list2);
return list1;
});
}
private String getExternalLMSServerAddress(final LmsSetup lmsSetup) {
final String externalAddressAlias = this.webserviceInfo.getExternalAddressAlias(lmsSetup.lmsApiUrl);
String _externalStartURI = lmsSetup.lmsApiUrl + OPEN_EDX_DEFAULT_COURSE_START_URL_PREFIX;
if (StringUtils.isNoneBlank(externalAddressAlias)) {
try {
final URL url = new URL(lmsSetup.lmsApiUrl);
final int port = url.getPort();
_externalStartURI = url.getProtocol() +
Constants.URL_ADDRESS_SEPARATOR + externalAddressAlias +
((port >= 0)
? Constants.URL_PORT_SEPARATOR + port
: StringUtils.EMPTY)
+ OPEN_EDX_DEFAULT_COURSE_START_URL_PREFIX;
if (log.isDebugEnabled()) {
log.debug("Use external address for course access: {}", _externalStartURI);
}
} catch (final Exception e) {
log.error("Failed to create external address from alias: ", e);
}
}
return _externalStartURI;
}
private List<CourseData> collectAllCourses(final String pageURI, final OAuth2RestTemplate restTemplate) {
final List<CourseData> collector = new ArrayList<>();
EdXPage page = getEdxPage(pageURI, restTemplate).getBody();
if (page != null) {
collector.addAll(page.results);
while (page != null && StringUtils.isNotBlank(page.next)) {
page = getEdxPage(page.next, restTemplate).getBody();
if (page != null) {
collector.addAll(page.results);
}
}
}
return collector;
}
private ResponseEntity<EdXPage> getEdxPage(final String pageURI, final OAuth2RestTemplate restTemplate) {
final HttpHeaders httpHeaders = new HttpHeaders();
return restTemplate.exchange(
pageURI,
HttpMethod.GET,
new HttpEntity<>(httpHeaders),
EdXPage.class);
}
private static QuizData quizDataOf(
final LmsSetup lmsSetup,
final CourseData courseData,
final String uriPrefix) {
final String startURI = uriPrefix + courseData.id;
final Map<String, String> additionalAttrs = new HashMap<>();
additionalAttrs.put("blocks_url", courseData.blocks_url);
return new QuizData(
courseData.id,
lmsSetup.getInstitutionId(),
lmsSetup.id,
lmsSetup.getLmsType(),
courseData.name,
courseData.short_description,
courseData.start,
courseData.end,
startURI);
}
/** Maps a OpenEdX course API course page */
static final class EdXPage {
public Integer count;
public String previous;
public Integer num_pages;
public String next;
public List<CourseData> results;
}
/** Maps the OpenEdX course API course data */
static final class CourseData {
public String id;
public String name;
public String short_description;
public String blocks_url;
public String start;
public String end;
}
private class EdxOAuth2RequestAuthenticator implements OAuth2RequestAuthenticator {
@Override
public void authenticate(
final OAuth2ProtectedResourceDetails resource,
final OAuth2ClientContext clientContext,
final ClientHttpRequest request) {
final OAuth2AccessToken accessToken = clientContext.getAccessToken();
if (accessToken == null) {
throw new AccessTokenRequiredException(resource);
}
request.getHeaders().set("Authorization", String.format("%s %s", "Bearer", accessToken.getValue()));
}
}
private Result<OAuth2RestTemplate> getRestTemplate() {
if (this.restTemplate == null) {
final Result<OAuth2RestTemplate> templateRequest = this.openEdxRestTemplateFactory
.createOAuthRestTemplate();
if (templateRequest.hasError()) {
return templateRequest;
} else {
this.restTemplate = templateRequest.get();
}
}
return Result.of(this.restTemplate);
}
}

View file

@ -0,0 +1,180 @@
/*
* Copyright (c) 2019 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.edx;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.web.client.HttpClientErrorException;
import ch.ethz.seb.sebserver.gbl.api.JSONMapper;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult;
import ch.ethz.seb.sebserver.gbl.util.Result;
public class OpenEdxCourseRestriction {
private static final Logger log = LoggerFactory.getLogger(OpenEdxCourseRestriction.class);
private static final String OPEN_EDX_DEFAULT_COURSE_RESTRICTION_API_INFO = "/seb-openedx/seb-info";
private static final String OPEN_EDX_DEFAULT_COURSE_RESTRICTION_API_PATH =
"/seb-openedx/api/v1/course/%s/configuration/";
private final LmsSetup lmsSetup;
private final JSONMapper jsonMapper;
private final OpenEdxRestTemplateFactory openEdxRestTemplateFactory;
private OAuth2RestTemplate restTemplate;
protected OpenEdxCourseRestriction(
final LmsSetup lmsSetup,
final JSONMapper jsonMapper,
final OpenEdxRestTemplateFactory openEdxRestTemplateFactory) {
this.lmsSetup = lmsSetup;
this.jsonMapper = jsonMapper;
this.openEdxRestTemplateFactory = openEdxRestTemplateFactory;
}
LmsSetupTestResult initAPIAccess() {
final LmsSetupTestResult attributesCheck = this.openEdxRestTemplateFactory.test();
if (!attributesCheck.isOk()) {
return attributesCheck;
}
final Result<OAuth2RestTemplate> restTemplateRequest = getRestTemplate();
if (restTemplateRequest.hasError()) {
return LmsSetupTestResult.ofTokenRequestError(
"Failed to gain access token from OpenEdX Rest API:\n tried token endpoints: " +
this.openEdxRestTemplateFactory.knownTokenAccessPaths);
}
final OAuth2RestTemplate restTemplate = restTemplateRequest.get();
// NOTE: since the OPEN_EDX_DEFAULT_COURSE_RESTRICTION_API_INFO endpoint is
// not accessible within OAuth2 authentication (just with user - authentication),
// we can only check if the endpoint is available for now. This is checked
// if there is no 404 response.
// TODO: Ask eduNEXT to implement also OAuth2 API access for this endpoint to be able
// to check the version of the installed plugin.
final String url = this.lmsSetup.lmsApiUrl + OPEN_EDX_DEFAULT_COURSE_RESTRICTION_API_INFO;
try {
restTemplate.exchange(
url,
HttpMethod.GET,
new HttpEntity<>(new HttpHeaders()),
Object.class);
} catch (final HttpClientErrorException e) {
if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
return LmsSetupTestResult.ofQuizRestrictionAPIError(
"Failed to verify course restriction API: " + e.getMessage());
}
if (log.isDebugEnabled()) {
log.debug("Sucessfully checked SEB Open edX integration Plugin");
}
}
return LmsSetupTestResult.ofOkay();
}
Result<OpenEdxCourseRestrictionData> pushSebRestriction(
final String courseId,
final OpenEdxCourseRestrictionData restriction) {
if (log.isDebugEnabled()) {
log.debug("PUT SEB Client restriction on course: {} : {}", courseId, restriction);
}
return getRestTemplate()
.map(restTemplate -> {
final String url = this.lmsSetup.lmsApiUrl + getSebRestrictionUrl(courseId);
final HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
try {
final String json = this.jsonMapper.writeValueAsString(restriction);
final OpenEdxCourseRestrictionData confirm = restTemplate.exchange(
url,
HttpMethod.PUT,
new HttpEntity<>(json, httpHeaders),
OpenEdxCourseRestrictionData.class)
.getBody();
if (log.isDebugEnabled()) {
log.debug("Successfully PUT SEB Client restriction on course: {} : {}", courseId, confirm);
}
return confirm;
} catch (final Exception e) {
throw new RuntimeException("Unexpected: ", e);
}
});
}
Result<Boolean> deleteSebRestriction(final String courseId) {
if (log.isDebugEnabled()) {
log.debug("DELETE SEB Client restriction on course: {}", courseId);
}
return getRestTemplate()
.map(restTemplate -> {
final String url = this.lmsSetup.lmsApiUrl + getSebRestrictionUrl(courseId);
final ResponseEntity<Object> exchange = restTemplate.exchange(
url,
HttpMethod.DELETE,
new HttpEntity<>(new HttpHeaders()),
Object.class);
if (exchange.getStatusCode() == HttpStatus.NO_CONTENT) {
if (log.isDebugEnabled()) {
log.debug("Successfully PUT SEB Client restriction on course: {}", courseId);
}
return true;
} else {
throw new RuntimeException("Unexpected response for deletion: " + exchange);
}
});
}
private String getSebRestrictionUrl(final String courseId) {
return String.format(OPEN_EDX_DEFAULT_COURSE_RESTRICTION_API_PATH, courseId);
}
private Result<OAuth2RestTemplate> getRestTemplate() {
if (this.restTemplate == null) {
final Result<OAuth2RestTemplate> templateRequest = this.openEdxRestTemplateFactory
.createOAuthRestTemplate();
if (templateRequest.hasError()) {
return templateRequest;
} else {
this.restTemplate = templateRequest.get();
}
}
return Result.of(this.restTemplate);
}
}

View file

@ -6,8 +6,9 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl;
package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.edx;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
@ -20,14 +21,15 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import ch.ethz.seb.sebserver.gbl.Constants;
import ch.ethz.seb.sebserver.gbl.model.institution.EdxCourseRestrictionAttributes;
import ch.ethz.seb.sebserver.gbl.util.Utils;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.SebRestrictionData;
@JsonIgnoreProperties(ignoreUnknown = true)
public class OpenEdxSebClientRestriction {
public class OpenEdxCourseRestrictionData {
private static final String ATTR_USER_BANNING_ENABLED = "USER_BANNING_ENABLED";
private static final String ATTR_SEB_PERMISSION_COMPONENTS = "SEB_PERMISSION_COMPONENTS";
private static final String ATTR_PERMISSION_COMPONENTS = "PERMISSION_COMPONENTS";
private static final String ATTR_BLACKLIST_CHAPTERS = "BLACKLIST_CHAPTERS";
private static final String ATTR_WHITELIST_PATHS = "WHITELIST_PATHS";
private static final String ATTR_BROWSER_KEYS = "BROWSER_KEYS";
@ -45,19 +47,19 @@ public class OpenEdxSebClientRestriction {
@JsonProperty(ATTR_BLACKLIST_CHAPTERS)
public final Collection<String> blacklistChapters;
@JsonProperty(ATTR_SEB_PERMISSION_COMPONENTS)
@JsonProperty(ATTR_PERMISSION_COMPONENTS)
public final Collection<String> permissionComponents;
@JsonProperty(ATTR_USER_BANNING_ENABLED)
public final boolean banningEnabled;
@JsonCreator
public OpenEdxSebClientRestriction(
public OpenEdxCourseRestrictionData(
@JsonProperty(ATTR_CONFIG_KEYS) final Collection<String> configKeys,
@JsonProperty(ATTR_BROWSER_KEYS) final Collection<String> browserExamKeys,
@JsonProperty(ATTR_WHITELIST_PATHS) final Collection<String> whiteListPaths,
@JsonProperty(ATTR_BLACKLIST_CHAPTERS) final Collection<String> blacklistChapters,
@JsonProperty(ATTR_SEB_PERMISSION_COMPONENTS) final Collection<String> permissionComponents,
@JsonProperty(ATTR_PERMISSION_COMPONENTS) final Collection<String> permissionComponents,
@JsonProperty(ATTR_USER_BANNING_ENABLED) final boolean banningEnabled) {
this.configKeys = Utils.immutableCollectionOf(configKeys);
@ -68,7 +70,7 @@ public class OpenEdxSebClientRestriction {
this.banningEnabled = banningEnabled;
}
public OpenEdxSebClientRestriction(final SebRestrictionData data) {
public OpenEdxCourseRestrictionData(final SebRestrictionData data) {
this.configKeys = Utils.immutableCollectionOf(data.configKeys);
this.browserExamKeys = Utils.immutableCollectionOf(data.browserExamKeys);
@ -88,12 +90,21 @@ public class OpenEdxSebClientRestriction {
this.blacklistChapters = Collections.emptyList();
}
final String permissionComponents = data.additionalAttributes.get(ATTR_SEB_PERMISSION_COMPONENTS);
final String permissionComponents = data.additionalAttributes.get(ATTR_PERMISSION_COMPONENTS);
if (StringUtils.isNotBlank(permissionComponents)) {
this.permissionComponents = Utils.immutableCollectionOf(Arrays.asList(
StringUtils.split(permissionComponents, Constants.LIST_SEPARATOR)));
} else {
this.permissionComponents = Collections.emptyList();
final Collection<String> defaultPermissions = new ArrayList<>();
defaultPermissions.add(EdxCourseRestrictionAttributes.PermissionComponents.ALWAYS_ALLOW_STUFF.name);
if (!this.configKeys.isEmpty()) {
defaultPermissions.add(EdxCourseRestrictionAttributes.PermissionComponents.CHECK_CONFIG_KEY.name);
}
if (!this.browserExamKeys.isEmpty()) {
defaultPermissions.add(EdxCourseRestrictionAttributes.PermissionComponents.CHECK_BROWSER_EXAM_KEY.name);
}
this.permissionComponents = Utils.immutableCollectionOf(defaultPermissions);
}
this.banningEnabled = BooleanUtils.toBoolean(data.additionalAttributes.get(ATTR_USER_BANNING_ENABLED));
@ -122,4 +133,24 @@ public class OpenEdxSebClientRestriction {
public boolean isBanningEnabled() {
return this.banningEnabled;
}
@Override
public String toString() {
final StringBuilder builder = new StringBuilder();
builder.append("OpenEdxCourseRestrictionData [configKeys=");
builder.append(this.configKeys);
builder.append(", browserExamKeys=");
builder.append(this.browserExamKeys);
builder.append(", whiteListPaths=");
builder.append(this.whiteListPaths);
builder.append(", blacklistChapters=");
builder.append(this.blacklistChapters);
builder.append(", permissionComponents=");
builder.append(this.permissionComponents);
builder.append(", banningEnabled=");
builder.append(this.banningEnabled);
builder.append("]");
return builder.toString();
}
}

View file

@ -0,0 +1,120 @@
/*
* Copyright (c) 2019 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.edx;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.institution.LmsSetup;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPITemplate;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.SebRestrictionData;
final class OpenEdxLmsAPITemplate implements LmsAPITemplate {
private static final Logger log = LoggerFactory.getLogger(OpenEdxLmsAPITemplate.class);
private final LmsSetup lmsSetup;
private final OpenEdxCourseAccess openEdxCourseAccess;
private final OpenEdxCourseRestriction openEdxCourseRestriction;
OpenEdxLmsAPITemplate(
final LmsSetup lmsSetup,
final OpenEdxCourseAccess openEdxCourseAccess,
final OpenEdxCourseRestriction openEdxCourseRestriction) {
this.lmsSetup = lmsSetup;
this.openEdxCourseAccess = openEdxCourseAccess;
this.openEdxCourseRestriction = openEdxCourseRestriction;
}
@Override
public LmsSetup lmsSetup() {
return this.lmsSetup;
}
@Override
public LmsSetupTestResult testCourseAccessAPI() {
return this.openEdxCourseAccess.initAPIAccess();
}
@Override
public LmsSetupTestResult testCourseRestrictionAPI() {
return this.openEdxCourseRestriction.initAPIAccess();
}
@Override
public Result<List<QuizData>> getQuizzes(final FilterMap filterMap) {
return this.openEdxCourseAccess.getQuizzes(filterMap);
}
@Override
public Collection<Result<QuizData>> getQuizzes(final Set<String> ids) {
// TODO this can be improved in the future
return getQuizzes(new FilterMap())
.getOrElse(() -> Collections.emptyList())
.stream()
.filter(quiz -> ids.contains(quiz.id))
.map(quiz -> Result.of(quiz))
.collect(Collectors.toList());
}
@Override
public Result<SebRestrictionData> applySebClientRestriction(final SebRestrictionData sebRestrictionData) {
if (log.isDebugEnabled()) {
log.debug("Apply SEB Client restriction: {}", sebRestrictionData);
}
return Result.tryCatch(() -> {
this.openEdxCourseRestriction.pushSebRestriction(
sebRestrictionData.exam.externalId,
new OpenEdxCourseRestrictionData(sebRestrictionData))
.getOrThrow();
return sebRestrictionData;
});
}
@Override
public Result<SebRestrictionData> updateSebClientRestriction(final SebRestrictionData sebRestrictionData) {
if (log.isDebugEnabled()) {
log.debug("Apply SEB Client restriction: {}", sebRestrictionData);
}
return this.openEdxCourseRestriction.pushSebRestriction(
sebRestrictionData.exam.externalId,
new OpenEdxCourseRestrictionData(sebRestrictionData))
.map(result -> sebRestrictionData);
}
@Override
public Result<Exam> releaseSebClientRestriction(final Exam exam) {
if (log.isDebugEnabled()) {
log.debug("Release SEB Client restriction for Exam: {}", exam);
}
return this.openEdxCourseRestriction.deleteSebRestriction(exam.externalId)
.map(result -> exam);
}
}

View file

@ -0,0 +1,82 @@
/*
* Copyright (c) 2019 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.edx;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import ch.ethz.seb.sebserver.ClientHttpRequestFactoryService;
import ch.ethz.seb.sebserver.gbl.Constants;
import ch.ethz.seb.sebserver.gbl.api.JSONMapper;
import ch.ethz.seb.sebserver.gbl.async.AsyncService;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup;
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.webservice.WebserviceInfo;
import ch.ethz.seb.sebserver.webservice.servicelayer.client.ClientCredentialService;
import ch.ethz.seb.sebserver.webservice.servicelayer.client.ClientCredentials;
@Lazy
@Service
@WebServiceProfile
public class OpenEdxLmsAPITemplateFactory {
private final JSONMapper jsonMapper;
private final WebserviceInfo webserviceInfo;
private final AsyncService asyncService;
private final ClientCredentialService clientCredentialService;
private final ClientHttpRequestFactoryService clientHttpRequestFactoryService;
private final String[] alternativeTokenRequestPaths;
protected OpenEdxLmsAPITemplateFactory(
final JSONMapper jsonMapper,
final WebserviceInfo webserviceInfo,
final AsyncService asyncService,
final ClientCredentialService clientCredentialService,
final ClientHttpRequestFactoryService clientHttpRequestFactoryService,
@Value("${sebserver.webservice.lms.openedx.api.token.request.paths}") final String alternativeTokenRequestPaths) {
this.jsonMapper = jsonMapper;
this.webserviceInfo = webserviceInfo;
this.asyncService = asyncService;
this.clientCredentialService = clientCredentialService;
this.clientHttpRequestFactoryService = clientHttpRequestFactoryService;
this.alternativeTokenRequestPaths = (alternativeTokenRequestPaths != null)
? StringUtils.split(alternativeTokenRequestPaths, Constants.LIST_SEPARATOR)
: null;
}
public Result<OpenEdxLmsAPITemplate> create(final LmsSetup lmsSetup, final ClientCredentials credentials) {
return Result.tryCatch(() -> {
final OpenEdxRestTemplateFactory openEdxRestTemplateFactory = new OpenEdxRestTemplateFactory(
lmsSetup,
credentials,
this.clientCredentialService,
this.clientHttpRequestFactoryService,
this.alternativeTokenRequestPaths);
final OpenEdxCourseAccess openEdxCourseAccess = new OpenEdxCourseAccess(
lmsSetup,
openEdxRestTemplateFactory,
this.webserviceInfo,
this.asyncService);
final OpenEdxCourseRestriction openEdxCourseRestriction = new OpenEdxCourseRestriction(
lmsSetup,
this.jsonMapper,
openEdxRestTemplateFactory);
return new OpenEdxLmsAPITemplate(
lmsSetup,
openEdxCourseAccess,
openEdxCourseRestriction);
});
}
}

View file

@ -0,0 +1,208 @@
/*
* Copyright (c) 2019 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.edx;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.client.resource.OAuth2AccessDeniedException;
import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails;
import org.springframework.security.oauth2.client.resource.UserRedirectRequiredException;
import org.springframework.security.oauth2.client.token.AccessTokenRequest;
import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsAccessTokenProvider;
import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsResourceDetails;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import ch.ethz.seb.sebserver.ClientHttpRequestFactoryService;
import ch.ethz.seb.sebserver.gbl.api.APIMessage;
import ch.ethz.seb.sebserver.gbl.api.ProxyData;
import ch.ethz.seb.sebserver.gbl.api.ProxyData.ProxyAuthType;
import ch.ethz.seb.sebserver.gbl.model.Domain.LMS_SETUP;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.webservice.servicelayer.client.ClientCredentialService;
import ch.ethz.seb.sebserver.webservice.servicelayer.client.ClientCredentials;
final class OpenEdxRestTemplateFactory {
private static final String OPEN_EDX_DEFAULT_TOKEN_REQUEST_PATH = "/oauth2/access_token";
final LmsSetup lmsSetup;
final ClientCredentials credentials;
final ClientHttpRequestFactoryService clientHttpRequestFactoryService;
final ClientCredentialService clientCredentialService;
final Set<String> knownTokenAccessPaths;
OpenEdxRestTemplateFactory(
final LmsSetup lmsSetup,
final ClientCredentials credentials,
final ClientCredentialService clientCredentialService,
final ClientHttpRequestFactoryService clientHttpRequestFactoryService,
final String[] alternativeTokenRequestPaths) {
this.lmsSetup = lmsSetup;
this.clientCredentialService = clientCredentialService;
this.credentials = credentials;
this.clientHttpRequestFactoryService = clientHttpRequestFactoryService;
this.knownTokenAccessPaths = new HashSet<>();
this.knownTokenAccessPaths.add(OPEN_EDX_DEFAULT_TOKEN_REQUEST_PATH);
if (alternativeTokenRequestPaths != null) {
this.knownTokenAccessPaths.addAll(Arrays.asList(alternativeTokenRequestPaths));
}
}
public LmsSetupTestResult test() {
final List<APIMessage> missingAttrs = new ArrayList<>();
if (StringUtils.isBlank(this.lmsSetup.lmsApiUrl)) {
missingAttrs.add(APIMessage.fieldValidationError(
LMS_SETUP.ATTR_LMS_URL,
"lmsSetup:lmsUrl:notNull"));
}
if (!this.credentials.hasClientId()) {
missingAttrs.add(APIMessage.fieldValidationError(
LMS_SETUP.ATTR_LMS_CLIENTNAME,
"lmsSetup:lmsClientname:notNull"));
}
if (!this.credentials.hasSecret()) {
missingAttrs.add(APIMessage.fieldValidationError(
LMS_SETUP.ATTR_LMS_CLIENTSECRET,
"lmsSetup:lmsClientsecret:notNull"));
}
if (!missingAttrs.isEmpty()) {
return LmsSetupTestResult.ofMissingAttributes(missingAttrs);
}
return LmsSetupTestResult.ofOkay();
}
Result<OAuth2RestTemplate> createOAuthRestTemplate() {
return this.knownTokenAccessPaths
.stream()
.map(this::createOAuthRestTemplate)
.filter(Result::hasValue)
.findFirst()
.orElse(Result.ofRuntimeError(
"Failed to find gain any access on paths: " + this.knownTokenAccessPaths));
}
Result<OAuth2RestTemplate> createOAuthRestTemplate(final String accessTokenPath) {
return Result.tryCatch(() -> {
final OAuth2RestTemplate template = createRestTemplate(
this.lmsSetup,
this.credentials,
accessTokenPath);
final OAuth2AccessToken accessToken = template.getAccessToken();
if (accessToken == null) {
throw new RuntimeException("Failed to gain access token on path: " + accessTokenPath);
}
return template;
});
}
private OAuth2RestTemplate createRestTemplate(
final LmsSetup lmsSetup,
final ClientCredentials credentials,
final String accessTokenRequestPath) throws URISyntaxException {
final CharSequence plainClientId = credentials.clientId;
final CharSequence plainClientSecret = this.clientCredentialService.getPlainClientSecret(credentials);
final ClientCredentialsResourceDetails details = new ClientCredentialsResourceDetails();
details.setAccessTokenUri(lmsSetup.lmsApiUrl + accessTokenRequestPath);
details.setClientId(plainClientId.toString());
details.setClientSecret(plainClientSecret.toString());
ClientHttpRequestFactory clientHttpRequestFactory = null;
if (lmsSetup.proxyAuthType != ProxyAuthType.NONE) {
final ClientCredentials proxyCredentials = new ClientCredentials(
lmsSetup.proxyAuthUsername,
lmsSetup.proxyAuthSecret);
// TODO check where we have to encrypt/decrypt the secret internally
CharSequence proxySecretPlain;
try {
proxySecretPlain = this.clientCredentialService.getPlainClientSecret(proxyCredentials);
} catch (final Exception e) {
proxySecretPlain = lmsSetup.proxyAuthSecret;
}
final URI uri = new URI(lmsSetup.lmsApiUrl);
final ProxyData proxyData = new ProxyData(
lmsSetup.proxyAuthType,
uri.getHost(),
uri.getPort(),
proxyCredentials.clientId,
proxySecretPlain);
clientHttpRequestFactory = this.clientHttpRequestFactoryService
.getClientHttpRequestFactory(proxyData)
.getOrThrow();
} else {
clientHttpRequestFactory = this.clientHttpRequestFactoryService
.getClientHttpRequestFactory()
.getOrThrow();
}
final OAuth2RestTemplate template = new OAuth2RestTemplate(details);
template.setRequestFactory(clientHttpRequestFactory);
template.setAccessTokenProvider(new EdxClientCredentialsAccessTokenProvider());
return template;
}
/** A custom ClientCredentialsAccessTokenProvider that adapts the access token request to Open edX
* access token request protocol using a form-URL-encoded POST request according to:
* https://course-catalog-api-guide.readthedocs.io/en/latest/authentication/index.html#getting-an-access-token */
private class EdxClientCredentialsAccessTokenProvider extends ClientCredentialsAccessTokenProvider {
@Override
public OAuth2AccessToken obtainAccessToken(
final OAuth2ProtectedResourceDetails details,
final AccessTokenRequest request)
throws UserRedirectRequiredException,
AccessDeniedException,
OAuth2AccessDeniedException {
if (details instanceof ClientCredentialsResourceDetails) {
final ClientCredentialsResourceDetails resource = (ClientCredentialsResourceDetails) details;
final HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE);
final MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "client_credentials");
params.add("client_id", resource.getClientId());
params.add("client_secret", resource.getClientSecret());
final OAuth2AccessToken retrieveToken = retrieveToken(request, resource, params, headers);
return retrieveToken;
} else {
return super.obtainAccessToken(details, request);
}
}
}
}

View file

@ -233,7 +233,7 @@ public class ExamAdministrationController extends EntityController<Exam, Exam> {
return this.entityDAO.byPK(modelId)
.flatMap(this.authorization::checkModify)
.flatMap(this::checkNoActiveSebClientConnections)
.flatMap(exam -> this.setSebRestriction(exam, sebRestriction))
.flatMap(exam -> this.applySebRestriction(exam, sebRestriction))
.flatMap(this.userActivityLogDAO::logModify)
.getOrThrow();
}
@ -336,7 +336,7 @@ public class ExamAdministrationController extends EntityController<Exam, Exam> {
return Result.of(exam);
}
private Result<Exam> setSebRestriction(final Exam exam, final boolean sebRestriction) {
private Result<Exam> applySebRestriction(final Exam exam, final boolean sebRestriction) {
return Result.tryCatch(() -> {
if (BooleanUtils.toBoolean(exam.lmsSebRestriction) == sebRestriction) {
return exam;

View file

@ -93,7 +93,7 @@ public class LmsSetupController extends ActivatableEntityController<LmsSetup, Lm
institutionId);
final LmsSetupTestResult result = this.lmsAPIService.getLmsAPITemplate(modelId)
.map(template -> template.testLmsSetup())
.map(this.lmsAPIService::test)
.getOrThrow();
if (result.missingLMSSetupAttribute != null && !result.missingLMSSetupAttribute.isEmpty()) {

View file

@ -39,6 +39,8 @@
<Logger name="ch.ethz.seb.sebserver.webservice.servicelayer.sebconfig" level="INFO" additivity="true" />
<Logger name="ch.ethz.seb.sebserver.webservice.servicelayer.session" level="DEBUG" additivity="true" />
<Logger name="ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.edx" level="DEBUG" additivity="true" />
<Logger name="ch.ethz.seb.sebserver.webservice.servicelayer.sebconfig.impl.SebExamConfigServiceImpl" level="TRACE" additivity="true" />
</springProfile>

View file

@ -218,6 +218,7 @@ sebserver.lmssetup.action.testsave=Test And Save
sebserver.lmssetup.action.test.ok=Successfully connected to the course API
sebserver.lmssetup.action.test.tokenRequestError=The API access was denied: {0}
sebserver.lmssetup.action.test.quizRequestError=Unable to request courses or quizzes from the course API of the LMS. {0}
sebserver.lmssetup.action.test.quizRestrictionError=Unable to access course restriction API of the LMS. {0}
sebserver.lmssetup.action.test.missingParameter=There is one or more missing connection parameter.<br/>Please check the connection parameter for this LMS Setup
sebserver.lmssetup.action.test.unknownError=An unexpected error happened while trying to connect to the LMS course API. {0}
sebserver.lmssetup.action.save=Save LMS Setup

View file

@ -473,7 +473,7 @@ public class LmsSetupAPITest extends AdministrationAPIIntegrationTester {
});
assertNotNull(testResult);
assertTrue(testResult.getOkStatus());
assertTrue(testResult.isQuizAccessOk());
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2019 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.edx;
import static org.junit.Assert.assertEquals;
import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.core.JsonProcessingException;
import ch.ethz.seb.sebserver.gbl.api.JSONMapper;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.SebRestrictionData;
public class OpenEdxCourseRestrictionDataTest {
@Test
public void testEmpty1() throws JsonProcessingException {
final JSONMapper mapper = new JSONMapper();
final OpenEdxCourseRestrictionData data = new OpenEdxCourseRestrictionData(null, null, null, null, null, false);
final String json = mapper.writeValueAsString(data);
assertEquals(
"{\"CONFIG_KEYS\":[],\"BROWSER_KEYS\":[],\"WHITELIST_PATHS\":[],\"BLACKLIST_CHAPTERS\":[],\"PERMISSION_COMPONENTS\":[\"AlwaysAllowStaff\"],\"USER_BANNING_ENABLED\":false}",
json);
}
@Test
public void testEmpty2() throws JsonProcessingException {
final JSONMapper mapper = new JSONMapper();
final OpenEdxCourseRestrictionData data =
new OpenEdxCourseRestrictionData(new SebRestrictionData(null, null, null, null));
final String json = mapper.writeValueAsString(data);
assertEquals(
"{\"CONFIG_KEYS\":[],\"BROWSER_KEYS\":[],\"WHITELIST_PATHS\":[],\"BLACKLIST_CHAPTERS\":[],\"PERMISSION_COMPONENTS\":[\"AlwaysAllowStaff\"],\"USER_BANNING_ENABLED\":false}",
json);
}
}