SEBSERV-29 implementation of quiz data search from and LmsAPITemplate

This commit is contained in:
anhefti 2019-03-14 13:32:26 +01:00
parent c51016a548
commit 7b2f7228af
49 changed files with 1371 additions and 714 deletions

View file

@ -14,6 +14,11 @@ import org.joda.time.format.DateTimeFormatter;
/** Global Constants used in SEB Server web-service as well as in web-gui component */
public final class Constants {
public static final long SECOND_IN_MILLIS = 1000;
public static final long MINUTE_IN_MILLIS = 60 * SECOND_IN_MILLIS;
public static final long HOUR_IN_MILLIS = 60 * MINUTE_IN_MILLIS;
public static final long DAY_IN_MILLIS = 24 * HOUR_IN_MILLIS;
public static final Character LIST_SEPARATOR_CHAR = ',';
public static final String LIST_SEPARATOR = ",";
public static final String EMPTY_NOTE = "--";

View file

@ -42,7 +42,9 @@ public final class API {
public static final String LMS_SETUP_ENDPOINT = "/lms_setup";
public static final String LMS_SETUP_TEST_PATH_SEGMENT = "/test";
public static final String LMS_SETUP_TEST_ENDPOINT = LMS_SETUP_ENDPOINT + LMS_SETUP_TEST_PATH_SEGMENT;
public static final String LMS_SETUP_TEST_ENDPOINT = LMS_SETUP_ENDPOINT
+ LMS_SETUP_TEST_PATH_SEGMENT
+ MODEL_ID_VAR_PATH_SEGMENT;
public static final String USER_ACCOUNT_ENDPOINT = "/useraccount";

View file

@ -169,25 +169,30 @@ public class APIMessage implements Serializable {
private static final long serialVersionUID = 1453431210820677296L;
private final APIMessage apiMessage;
private final Collection<APIMessage> apiMessages;
public APIMessageException(final Collection<APIMessage> apiMessages) {
super();
this.apiMessages = apiMessages;
}
public APIMessageException(final APIMessage apiMessage) {
super();
this.apiMessage = apiMessage;
this.apiMessages = Arrays.asList(apiMessage);
}
public APIMessageException(final ErrorMessage errorMessage) {
super();
this.apiMessage = errorMessage.of();
this.apiMessages = Arrays.asList(errorMessage.of());
}
public APIMessageException(final ErrorMessage errorMessage, final String detail, final String... attributes) {
super();
this.apiMessage = errorMessage.of(detail, attributes);
this.apiMessages = Arrays.asList(errorMessage.of(detail, attributes));
}
public APIMessage getAPIMessage() {
return this.apiMessage;
public Collection<APIMessage> getAPIMessages() {
return this.apiMessages;
}
}

View file

@ -27,8 +27,6 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.GrantEntity;
public final class Exam implements GrantEntity, Activatable {
public static final String FILTER_ATTR_LMS_SETUP = "lms_setup";
public static final String FILTER_ATTR_NAME = "name_like";
public static final String FILTER_ATTR_STATUS = "status";
public static final String FILTER_ATTR_TYPE = "type";
public static final String FILTER_ATTR_FROM = "from";

View file

@ -8,6 +8,8 @@
package ch.ethz.seb.sebserver.gbl.model.exam;
import java.util.Comparator;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.joda.time.LocalDateTime;
@ -16,10 +18,12 @@ import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import ch.ethz.seb.sebserver.gbl.Constants;
import ch.ethz.seb.sebserver.gbl.api.EntityType;
import ch.ethz.seb.sebserver.gbl.model.Entity;
import ch.ethz.seb.sebserver.webservice.servicelayer.PaginationService.SortOrder;
public final class QuizData {
public final class QuizData implements Entity {
public static final String FILTER_ATTR_NAME = "name_like";
public static final String FILTER_ATTR_START_TIME = "start_timestamp";
public static final String QUIZ_ATTR_ID = "quiz_id";
@ -84,10 +88,25 @@ public final class QuizData {
this.startURL = startURL;
}
@Override
public String getModelId() {
if (this.id == null) {
return null;
}
return String.valueOf(this.id);
}
@Override
public EntityType entityType() {
return EntityType.EXAM;
}
public String geId() {
return this.id;
}
@Override
public String getName() {
return this.name;
}
@ -140,4 +159,62 @@ public final class QuizData {
+ ", endTime=" + this.endTime + ", startURL=" + this.startURL + "]";
}
public static Comparator<QuizData> getIdComparator(final boolean descending) {
return (qd1, qd2) -> ((qd1 == qd2)
? 0
: (qd1 == null || qd1.id == null)
? 1
: (qd2 == null || qd2.id == null)
? -1
: qd1.id.compareTo(qd2.id))
* ((descending) ? -1 : 1);
}
public static Comparator<QuizData> getNameComparator(final boolean descending) {
return (qd1, qd2) -> ((qd1 == qd2)
? 0
: (qd1 == null || qd1.name == null)
? 1
: (qd2 == null || qd2.name == null)
? -1
: qd1.name.compareTo(qd2.name))
* ((descending) ? -1 : 1);
}
public static Comparator<QuizData> getStartTimeComparator(final boolean descending) {
return (qd1, qd2) -> ((qd1 == qd2)
? 0
: (qd1 == null || qd1.startTime == null)
? 1
: (qd2 == null || qd2.startTime == null)
? -1
: qd1.startTime.compareTo(qd2.startTime))
* ((descending) ? -1 : 1);
}
public static Comparator<QuizData> getEndTimeComparator(final boolean descending) {
return (qd1, qd2) -> ((qd1 == qd2)
? 0
: (qd1 == null || qd1.endTime == null)
? 1
: (qd2 == null || qd2.endTime == null)
? -1
: qd1.endTime.compareTo(qd2.endTime))
* ((descending) ? -1 : 1);
}
public static Comparator<QuizData> getComparator(final String sort) {
final boolean descending = SortOrder.getSortOrder(sort) == SortOrder.DESCENDING;
final String sortParam = SortOrder.decode(sort);
if (QUIZ_ATTR_NAME.equals(sortParam)) {
return getNameComparator(descending);
} else if (QUIZ_ATTR_START_TIME.equals(sortParam)) {
return getStartTimeComparator(descending);
} else if (QUIZ_ATTR_END_TIME.equals(sortParam)) {
return getEndTimeComparator(descending);
}
return getIdComparator(descending);
}
}

View file

@ -11,6 +11,8 @@ package ch.ethz.seb.sebserver.gbl.model.institution;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import org.hibernate.validator.constraints.URL;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@ -26,11 +28,11 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.GrantEntity;
public final class LmsSetup implements GrantEntity, Activatable {
public static final String FILTER_ATTR_LMS_SETUP = "lms_setup";
public static final String FILTER_ATTR_LMS_TYPE = "lms_type";
public enum LmsType {
MOCKUP,
MOODLE,
OPEN_EDX
}
@ -51,14 +53,13 @@ public final class LmsSetup implements GrantEntity, Activatable {
public final LmsType lmsType;
@JsonProperty(LMS_SETUP.ATTR_LMS_CLIENTNAME)
@Size(min = 3, max = 255, message = "lmsSetup:lmsClientname:size:{min}:{max}:${validatedValue}")
public final String lmsAuthName;
@JsonProperty(LMS_SETUP.ATTR_LMS_CLIENTSECRET)
@Size(min = 8, max = 255, message = "lmsSetup:lmsClientsecret:size:{min}:{max}:${validatedValue}")
public final String lmsAuthSecret;
@JsonProperty(LMS_SETUP.ATTR_LMS_URL)
@URL(message = "lmsSetup:lmsUrl:invalidURL")
public final String lmsApiUrl;
@JsonProperty(LMS_SETUP.ATTR_LMS_REST_API_TOKEN)

View file

@ -11,12 +11,14 @@ package ch.ethz.seb.sebserver.gbl.model.institution;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Set;
import java.util.List;
import javax.validation.constraints.NotNull;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import ch.ethz.seb.sebserver.gbl.api.APIMessage;
import ch.ethz.seb.sebserver.gbl.util.Utils;
public final class LmsSetupTestResult {
@ -31,32 +33,37 @@ public final class LmsSetupTestResult {
public final Boolean okStatus;
@JsonProperty(ATTR_MISSING_ATTRIBUTE)
public final Set<String> missingLMSSetupAttribute;
public final List<APIMessage> missingLMSSetupAttribute;
@JsonProperty(ATTR_MISSING_ATTRIBUTE)
@JsonProperty(ATTR_ERROR_TOKEN_REQUEST)
public final String tokenRequestError;
@JsonProperty(ATTR_MISSING_ATTRIBUTE)
@JsonProperty(ATTR_ERROR_QUIZ_REQUEST)
public final String quizRequestError;
public LmsSetupTestResult(
@JsonProperty(value = ATTR_OK_STATUS, required = true) final Boolean ok,
@JsonProperty(ATTR_MISSING_ATTRIBUTE) final Collection<String> missingLMSSetupAttribute,
@JsonProperty(ATTR_MISSING_ATTRIBUTE) final String tokenRequestError,
@JsonProperty(ATTR_MISSING_ATTRIBUTE) final String quizRequestError) {
@JsonProperty(ATTR_MISSING_ATTRIBUTE) final Collection<APIMessage> missingLMSSetupAttribute,
@JsonProperty(ATTR_ERROR_TOKEN_REQUEST) final String tokenRequestError,
@JsonProperty(ATTR_ERROR_QUIZ_REQUEST) final String quizRequestError) {
this.okStatus = ok;
// TODO
this.missingLMSSetupAttribute = Utils.immutableSetOf(missingLMSSetupAttribute);
this.missingLMSSetupAttribute = Utils.immutableListOf(missingLMSSetupAttribute);
this.tokenRequestError = tokenRequestError;
this.quizRequestError = quizRequestError;
}
@JsonIgnore
public boolean isOk() {
return this.okStatus != null && this.okStatus.booleanValue();
}
public Boolean getOkStatus() {
return this.okStatus;
}
public Set<String> getMissingLMSSetupAttribute() {
public List<APIMessage> getMissingLMSSetupAttribute() {
return this.missingLMSSetupAttribute;
}
@ -79,11 +86,11 @@ public final class LmsSetupTestResult {
return new LmsSetupTestResult(true, Collections.emptyList(), null, null);
}
public static final LmsSetupTestResult ofMissingAttributes(final Collection<String> attrs) {
public static final LmsSetupTestResult ofMissingAttributes(final Collection<APIMessage> attrs) {
return new LmsSetupTestResult(false, attrs, null, null);
}
public static final LmsSetupTestResult ofMissingAttributes(final String... attrs) {
public static final LmsSetupTestResult ofMissingAttributes(final APIMessage... attrs) {
if (attrs == null) {
return new LmsSetupTestResult(false, Collections.emptyList(), null, null);
}

View file

@ -13,6 +13,9 @@ import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** A result of a computation that can either be the resulting value of the computation
* or an error if an exception/error has been thrown during the computation.
*
@ -49,6 +52,8 @@ import java.util.stream.Stream;
* @param <T> The type of the result value */
public final class Result<T> {
private static final Logger log = LoggerFactory.getLogger(Result.class);
/** The resulting value. May be null if an error occurred */
private final T value;
/** The error when happened otherwise null */
@ -270,6 +275,15 @@ public final class Result<T> {
}
}
public static <T> Stream<T> onErrorLogAndSkip(final Result<T> result) {
if (result.error != null) {
log.error("Unexpected error on result. Cause: ", result.error);
return Stream.empty();
} else {
return Stream.of(result.value);
}
}
@Override
public int hashCode() {
final int prime = 31;

View file

@ -0,0 +1,42 @@
/*
* 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.util;
import java.util.function.Supplier;
public class SupplierWithCircuitBreaker<T> implements Supplier<Result<T>> {
private final Supplier<T> supplierThatCanFailOrBlock;
private final int maxFailingAttempts;
private final long maxBlockingTime;
private final T cached = null;
public SupplierWithCircuitBreaker(
final Supplier<T> supplierThatCanFailOrBlock,
final int maxFailingAttempts,
final long maxBlockingTime) {
this.supplierThatCanFailOrBlock = supplierThatCanFailOrBlock;
this.maxFailingAttempts = maxFailingAttempts;
this.maxBlockingTime = maxBlockingTime;
}
@Override
public Result<T> get() {
// TODO start an async task that calls the supplierThatCanFailOrBlock and returns a Future
// try to get the result periodically until maxBlockingTime
// if the supplier returns error, try for maxFailingAttempts
// if success cache and return the result
// if failed return the cached values
return Result.tryCatch(() -> this.supplierThatCanFailOrBlock.get());
}
}

View file

@ -155,4 +155,10 @@ public final class Utils {
}
}
public static final String formatHTMLLines(final String message) {
return (message != null)
? message.replace("\n", "<br/>")
: null;
}
}

View file

@ -174,7 +174,7 @@ public class InstitutionForm implements TemplateComposer {
.publishIf(() -> writeGrant && isReadonly && !institution.isActive())
.createAction(ActionDefinition.INSTITUTION_SAVE)
.withExec(formHandle::postChanges)
.withExec(formHandle::processFormSave)
.publishIf(() -> !isReadonly)
.createAction(ActionDefinition.INSTITUTION_CANCEL_MODIFY)

View file

@ -10,6 +10,7 @@ package ch.ethz.seb.sebserver.gui.content;
import java.util.function.BooleanSupplier;
import org.apache.commons.lang3.StringUtils;
import org.eclipse.swt.widgets.Composite;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -21,9 +22,11 @@ import ch.ethz.seb.sebserver.gbl.model.Domain;
import ch.ethz.seb.sebserver.gbl.model.EntityKey;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup.LmsType;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult;
import ch.ethz.seb.sebserver.gbl.model.user.UserInfo;
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;
import ch.ethz.seb.sebserver.gui.content.action.ActionDefinition;
import ch.ethz.seb.sebserver.gui.form.FormBuilder;
import ch.ethz.seb.sebserver.gui.form.FormHandle;
@ -31,6 +34,7 @@ import ch.ethz.seb.sebserver.gui.form.PageFormService;
import ch.ethz.seb.sebserver.gui.service.ResourceService;
import ch.ethz.seb.sebserver.gui.service.i18n.LocTextKey;
import ch.ethz.seb.sebserver.gui.service.page.PageContext;
import ch.ethz.seb.sebserver.gui.service.page.PageMessageException;
import ch.ethz.seb.sebserver.gui.service.page.PageUtils;
import ch.ethz.seb.sebserver.gui.service.page.TemplateComposer;
import ch.ethz.seb.sebserver.gui.service.page.action.Action;
@ -39,6 +43,7 @@ import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.institution.GetIn
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.lmssetup.GetLmsSetup;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.lmssetup.NewLmsSetup;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.lmssetup.SaveLmsSetup;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.lmssetup.TestLmsSetup;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.auth.CurrentUser;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.auth.CurrentUser.EntityGrantCheck;
import ch.ethz.seb.sebserver.gui.widget.WidgetFactory;
@ -126,6 +131,9 @@ public class LmsSetupForm implements TemplateComposer {
.putStaticValueIf(isNotNew,
Domain.LMS_SETUP.ATTR_INSTITUTION_ID,
String.valueOf(lmsSetup.getInstitutionId()))
.putStaticValueIf(isNotNew,
Domain.LMS_SETUP.ATTR_LMS_TYPE,
String.valueOf(lmsSetup.getLmsType()))
.addField(FormBuilder.singleSelection(
Domain.LMS_SETUP.ATTR_INSTITUTION_ID,
"sebserver.lmssetup.form.institution",
@ -143,22 +151,21 @@ public class LmsSetupForm implements TemplateComposer {
(lmsType != null) ? lmsType.name() : null,
this.resourceService::lmsTypeResources)
.readonlyIf(isNotNew))
.addField(FormBuilder.text(
Domain.LMS_SETUP.ATTR_LMS_URL,
"sebserver.lmssetup.form.url",
lmsSetup.getLmsApiUrl())
.withCondition(() -> isNotNew.getAsBoolean() && lmsType != LmsType.MOCKUP))
.withCondition(() -> isNotNew.getAsBoolean()))
.addField(FormBuilder.text(
Domain.LMS_SETUP.ATTR_LMS_CLIENTNAME,
"sebserver.lmssetup.form.clientname.lms",
lmsSetup.getLmsAuthName())
.withCondition(() -> isNotNew.getAsBoolean() && lmsType != LmsType.MOCKUP))
.withCondition(() -> isNotNew.getAsBoolean()))
.addField(FormBuilder.text(
Domain.LMS_SETUP.ATTR_LMS_CLIENTSECRET,
"sebserver.lmssetup.form.secret.lms")
.asPasswordField()
.withCondition(() -> isNotNew.getAsBoolean() && lmsType != LmsType.MOCKUP))
.withCondition(() -> isNotNew.getAsBoolean()))
.buildFor((entityKey == null)
? restService.getRestCall(NewLmsSetup.class)
@ -176,6 +183,11 @@ public class LmsSetupForm implements TemplateComposer {
.withEntityKey(entityKey)
.publishIf(() -> modifyGrant && readonly && istitutionActive)
.createAction(ActionDefinition.LMS_SETUP_TEST)
.withEntityKey(entityKey)
.withExec(action -> this.testLmsSetup(action, formHandle))
.publishIf(() -> modifyGrant && isNotNew.getAsBoolean() && istitutionActive)
.createAction(ActionDefinition.LMS_SETUP_DEACTIVATE)
.withEntityKey(entityKey)
.withExec(restService::activation)
@ -188,7 +200,7 @@ public class LmsSetupForm implements TemplateComposer {
.publishIf(() -> writeGrant && readonly && istitutionActive && !lmsSetup.isActive())
.createAction(ActionDefinition.LMS_SETUP_SAVE)
.withExec(formHandle::postChanges)
.withExec(formHandle::processFormSave)
.publishIf(() -> !readonly)
.createAction(ActionDefinition.LMS_SETUP_CANCEL_MODIFY)
@ -199,4 +211,50 @@ public class LmsSetupForm implements TemplateComposer {
}
/** LmsSetup test action implementation */
private Action testLmsSetup(final Action action, final FormHandle<LmsSetup> formHandle) {
// If we are in edit-mode we have to save the form before testing
if (!action.pageContext().isReadonly()) {
final Result<LmsSetup> postResult = formHandle.doAPIPost();
if (postResult.hasError()) {
formHandle.handleError(postResult.getError());
postResult.getOrThrow();
}
}
// Call the testing endpoint with the specified data to test
final EntityKey entityKey = action.getEntityKey();
final RestService restService = this.resourceService.getRestService();
final Result<LmsSetupTestResult> result = restService.getBuilder(TestLmsSetup.class)
.withURIVariable(API.PARAM_MODEL_ID, entityKey.getModelId())
.call();
// ... and handle the response
if (result.hasError()) {
if (formHandle.handleError(result.getError())) {
throw new PageMessageException(
new LocTextKey("sebserver.lmssetup.action.test.missingParameter"));
}
}
final LmsSetupTestResult testResult = result.getOrThrow();
if (testResult.isOk()) {
action.pageContext().publishInfo(
new LocTextKey("sebserver.lmssetup.action.test.ok"));
return action;
} else if (StringUtils.isNoneBlank(testResult.tokenRequestError)) {
throw new PageMessageException(
new LocTextKey("sebserver.lmssetup.action.test.tokenRequestError",
testResult.tokenRequestError));
} else if (StringUtils.isNoneBlank(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));
}
}
}

View file

@ -105,7 +105,7 @@ public class UserAccountChangePasswordForm implements TemplateComposer {
pageContext.createAction(ActionDefinition.USER_ACCOUNT_CHANGE_PASSOWRD_SAVE)
.withExec(action -> {
formHandle.postChanges(action);
formHandle.processFormSave(action);
if (ownAccount) {
// NOTE: in this case the user changed the password of the own account
// this should cause an logout with specified message that password change

View file

@ -213,7 +213,7 @@ public class UserAccountForm implements TemplateComposer {
.createAction(ActionDefinition.USER_ACCOUNT_SAVE)
.withExec(action -> {
final Action postChanges = formHandle.postChanges(action);
final Action postChanges = formHandle.processFormSave(action);
if (ownAccount) {
currentUser.refresh();
pageContext.forwardToMainPage();

View file

@ -182,6 +182,11 @@ public enum ActionDefinition {
ImageIcon.EDIT,
LmsSetupForm.class,
LMS_SETUP_VIEW_LIST, false),
LMS_SETUP_TEST(
new LocTextKey("sebserver.lmssetup.action.test"),
ImageIcon.TEST,
LmsSetupForm.class,
LMS_SETUP_VIEW_LIST),
LMS_SETUP_CANCEL_MODIFY(
new LocTextKey("sebserver.overall.action.modify.cancel"),
ImageIcon.CANCEL,
@ -213,13 +218,13 @@ public enum ActionDefinition {
public final Class<? extends RestCall<?>> restCallType;
public final ActionDefinition activityAlias;
public final String category;
public final boolean readonly;
public final Boolean readonly;
private ActionDefinition(
final LocTextKey title,
final Class<? extends TemplateComposer> contentPaneComposer) {
this(title, null, contentPaneComposer, ActionPane.class, null, null, null, true);
this(title, null, contentPaneComposer, ActionPane.class, null, null, null, null);
}
private ActionDefinition(
@ -227,7 +232,7 @@ public enum ActionDefinition {
final Class<? extends TemplateComposer> contentPaneComposer,
final ActionDefinition activityAlias) {
this(title, null, contentPaneComposer, ActionPane.class, null, activityAlias, null, true);
this(title, null, contentPaneComposer, ActionPane.class, null, activityAlias, null, null);
}
private ActionDefinition(
@ -236,7 +241,7 @@ public enum ActionDefinition {
final Class<? extends TemplateComposer> contentPaneComposer,
final ActionDefinition activityAlias) {
this(title, icon, contentPaneComposer, ActionPane.class, null, activityAlias, null, true);
this(title, icon, contentPaneComposer, ActionPane.class, null, activityAlias, null, null);
}
private ActionDefinition(
@ -246,7 +251,7 @@ public enum ActionDefinition {
final Class<? extends RestCall<?>> restCallType,
final ActionDefinition activityAlias) {
this(title, icon, contentPaneComposer, ActionPane.class, restCallType, activityAlias, null, true);
this(title, icon, contentPaneComposer, ActionPane.class, restCallType, activityAlias, null, null);
}
private ActionDefinition(
@ -267,7 +272,7 @@ public enum ActionDefinition {
final Class<? extends RestCall<?>> restCallType,
final ActionDefinition activityAlias,
final String category,
final boolean readonly) {
final Boolean readonly) {
this.title = title;
this.icon = icon;

View file

@ -162,6 +162,15 @@ public final class Form implements FormBinding {
}
}
public boolean hasAnyError() {
return this.formFields.entrySet()
.stream()
.flatMap(entity -> entity.getValue().stream())
.filter(a -> a.hasError)
.findFirst()
.isPresent();
}
public void process(
final Predicate<String> nameFilter,
final Consumer<FormFieldAccessor> processor) {

View file

@ -13,9 +13,9 @@ import java.util.function.Consumer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ch.ethz.seb.sebserver.gbl.api.APIMessage;
import ch.ethz.seb.sebserver.gbl.model.Entity;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.gui.content.action.ActionDefinition;
import ch.ethz.seb.sebserver.gui.form.Form.FormFieldAccessor;
import ch.ethz.seb.sebserver.gui.service.i18n.I18nSupport;
import ch.ethz.seb.sebserver.gui.service.i18n.LocTextKey;
@ -50,12 +50,24 @@ public class FormHandle<T extends Entity> {
this.i18nSupport = i18nSupport;
}
public final Action postChanges(final Action action) {
return doAPIPost(action.definition)
.getOrThrow();
/** Process an API post request to send and save the form field values
* to the webservice and publishes a page event to return to read-only-view
* to indicate that the data was successfully saved or process an validation
* error indication if there are some validation errors.
*
* @param action the save action context
* @return the new Action context for read-only-view */
public final Action processFormSave(final Action action) {
return handleFormPost(doAPIPost(), action);
}
public Result<Action> doAPIPost(final ActionDefinition actionDefinition) {
/** process a form post by first resetting all field validation errors (if there are some)
* then collecting all input data from the form by form-binding to a either a JSON string in
* HTTP PUT case or to an form-URL-encoded string on HTTP POST case. And PUT or POST the data
* to the webservice by using the defined RestCall and return the response result of the RestCall.
*
* @return the response result of the post (or put) RestCall */
public Result<T> doAPIPost() {
this.form.process(
name -> true,
fieldAccessor -> fieldAccessor.resetError());
@ -63,34 +75,52 @@ public class FormHandle<T extends Entity> {
return this.post
.newBuilder()
.withFormBinding(this.form)
.call()
.map(result -> {
final Action action = this.pageContext.createAction(actionDefinition)
.withAttribute(AttributeKeys.READ_ONLY, "true")
.withEntityKey(result.getEntityKey());
this.pageContext.publishPageEvent(new ActionEvent(action, false));
return action;
})
.onErrorDo(this::handleError)
//.map(this.postPostHandle)
;
.call();
}
private void handleError(final Throwable error) {
/** Uses the result of a form post to either create and publish a new Action to
* go to the read-only-view of the specified form to indicate a successful form post
* or stay within the edit-mode of the form and indicate errors or field validation messages
* to the user on error case.
*
* @param postResult The form post result
* @param action the action that was applied with the form post
* @return the new Action that was used to stay on page or go the read-only-view of the form */
public Action handleFormPost(final Result<T> postResult, final Action action) {
return postResult
.map(result -> {
final Action resultAction = action.createNew()
.withAttribute(AttributeKeys.READ_ONLY, "true")
.withEntityKey(result.getEntityKey());
action.pageContext().publishPageEvent(new ActionEvent(resultAction, false));
return resultAction;
})
.onErrorDo(this::handleError)
.getOrThrow();
}
public boolean handleError(final Throwable error) {
if (error instanceof RestCallError) {
((RestCallError) error)
.getErrorMessages()
.stream()
.filter(APIMessage.ErrorMessage.FIELD_VALIDATION::isOf)
.map(FieldValidationError::new)
.forEach(fve -> this.form.process(
name -> name.equals(fve.fieldName),
fieldAccessor -> showValidationError(fieldAccessor, fve)));
return true;
} else {
log.error("Unexpected error while trying to post form: ", error);
this.pageContext.notifyError(error);
return false;
}
}
public boolean hasAnyError() {
return this.form.hasAnyError();
}
private final void showValidationError(
final FormFieldAccessor fieldAccessor,
final FieldValidationError valError) {

View file

@ -206,6 +206,14 @@ public interface PageContext {
* @param message the localized text key of the message */
void publishPageMessage(LocTextKey title, LocTextKey message);
/** Publish an information message to the user with the given localized message.
* The message text can also be HTML text as far as RWT supports it
*
* @param message the localized text key of the message */
default void publishInfo(final LocTextKey message) {
publishPageMessage(new LocTextKey("sebserver.page.message"), message);
}
/** Publish and shows a formatted PageMessageException to the user.
*
* @param pme the PageMessageException */

View file

@ -48,9 +48,12 @@ public final class Action implements Runnable {
this.definition = definition;
this.originalPageContext = pageContext;
final String readonly = pageContext.getAttribute(AttributeKeys.READ_ONLY, "true");
this.pageContext = pageContext.withAttribute(
AttributeKeys.READ_ONLY,
String.valueOf(definition.readonly));
definition.readonly != null
? String.valueOf(definition.readonly)
: readonly);
}
@Override
@ -89,6 +92,10 @@ public final class Action implements Runnable {
}
}
public Action createNew() {
return this.pageContext.createAction(this.definition);
}
public Action withExec(final Function<Action, Action> exec) {
this.exec = exec;
return this;

View file

@ -313,7 +313,7 @@ public class PageContextImpl implements PageContext {
final MessageBox messageBox = new Message(
getShell(),
this.i18nSupport.getText("sebserver.error.unexpected"),
error.toString(),
Utils.formatHTMLLines(errorMessage),
SWT.ERROR);
messageBox.open(null);
}

View file

@ -0,0 +1,40 @@
/*
* 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.gui.service.remote.webservice.api.lmssetup;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.core.type.TypeReference;
import ch.ethz.seb.sebserver.gbl.api.API;
import ch.ethz.seb.sebserver.gbl.api.EntityType;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult;
import ch.ethz.seb.sebserver.gbl.profile.GuiProfile;
import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.RestCall;
@Lazy
@Component
@GuiProfile
public class TestLmsSetup extends RestCall<LmsSetupTestResult> {
protected TestLmsSetup() {
super(new TypeKey<>(
CallType.UNDEFINED,
EntityType.LMS_SETUP,
new TypeReference<LmsSetupTestResult>() {
}),
HttpMethod.GET,
MediaType.APPLICATION_FORM_URLENCODED,
API.LMS_SETUP_TEST_ENDPOINT);
}
}

View file

@ -61,6 +61,7 @@ public class WidgetFactory {
MAXIMIZE("maximize.png"),
MINIMIZE("minimize.png"),
EDIT("edit.png"),
TEST("test.png"),
CANCEL("cancel.png"),
CANCEL_EDIT("cancelEdit.png"),
SHOW("show.png"),

View file

@ -23,16 +23,29 @@ import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
/** The MyBatis - Spring configuration
*
* All mapper- and model-classes in the specified sub-packages
* are auto-generated from DB schema by an external generator
*
* MyBatis is used on the lowest data - layer as an OR-Mapper with great flexibility and a good
* SQL builder interface.
*
* The Datasource is auto-configured by Spring and depends on the Spring property configuration so far */
@Configuration
@MapperScan(basePackages = "ch.ethz.seb.sebserver.webservice.datalayer.batis")
@WebServiceProfile
@Import(DataSourceAutoConfiguration.class)
public class BatisConfig {
/** Name of the transaction manager bean for MyBatis based Spring controlled transactions */
public static final String TRANSACTION_MANAGER = "transactionManager";
/** Name of the sql session template bean of MyBatis */
public static final String SQL_SESSION_TEMPLATE = "sqlSessionTemplate";
/** Name of the sql session factory bean of MyBatis */
public static final String SQL_SESSION_FACTORY = "sqlSessionFactory";
/** Transaction manager bean for MyBatis based Spring controlled transactions */
@Lazy
@Bean(name = SQL_SESSION_FACTORY)
public SqlSessionFactory sqlSessionFactory(final DataSource dataSource) throws Exception {
@ -41,6 +54,7 @@ public class BatisConfig {
return factoryBean.getObject();
}
/** SQL session template bean of MyBatis */
@Lazy
@Bean(name = SQL_SESSION_TEMPLATE)
public SqlSessionTemplate sqlSessionTemplate(final DataSource dataSource) throws Exception {
@ -48,6 +62,7 @@ public class BatisConfig {
return sqlSessionTemplate;
}
/** SQL session factory bean of MyBatis */
@Lazy
@Bean(name = TRANSACTION_MANAGER)
public DataSourceTransactionManager transactionManager(final DataSource dataSource) {

View file

@ -229,6 +229,16 @@ public interface AuthorizationService {
return check(PrivilegeType.WRITE, grantEntity);
}
/** Checks if the current user has role based view access to a specified user account.
*
* If user account has UserRole.SEB_SERVER_ADMIN this always gives true
* If user account has UserRole.INSTITUTIONAL_ADMIN this is true if the given user account has
* not the UserRole.SEB_SERVER_ADMIN (institutional administrators should not see SEB Server administrators)
* If the current user is the same as the given user account this is always true no matter if there are any
* user-account based privileges (every user shall see its own account)
*
* @param userAccount the user account the check role based view access
* @return true if the current user has role based view access to a specified user account */
default boolean hasRoleBasedUserAccountViewGrant(final UserInfo userAccount) {
final EnumSet<UserRole> userRolesOfUserAccount = userAccount.getUserRoles();
final SEBServerUser currentUser = getUserService().getCurrentUser();

View file

@ -50,6 +50,10 @@ public interface UserService {
* @return an overall super user with all rights */
SEBServerUser getSuperUser();
/** Binds the current users institution identifier as default value to a
*
* @RequestParam of type API.PARAM_INSTITUTION_ID if needed. See EntityController class for example
* @param binder Springs WebDataBinder is injected on controller side */
void addUsersInstitutionDefaultPropertySupport(final WebDataBinder binder);
}

View file

@ -16,22 +16,30 @@ import java.util.LinkedHashSet;
import java.util.Set;
import java.util.stream.Collectors;
import ch.ethz.seb.sebserver.gbl.api.EntityType;
import ch.ethz.seb.sebserver.gbl.api.API.BulkActionType;
import ch.ethz.seb.sebserver.gbl.api.EntityType;
import ch.ethz.seb.sebserver.gbl.model.EntityKey;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.gbl.util.Utils;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.UserActivityLogDAO.ActivityType;
/** Defines a bulk action with its type, source entities (and source-type) and dependent entities.
* A BulkAction acts as a collector for entities (keys) that depends on the Bulk Action during the
* dependency collection phase.
* A BulkAction also acts as a result collector during the bulk-action process phase. */
public final class BulkAction {
/** Defines the type of the BulkAction */
public final BulkActionType type;
/** Defines the EntityType of the source entities of the BulkAction */
public final EntityType sourceType;
/** A Set of EntityKey defining all source-entities of the BulkAction */
public final Set<EntityKey> sources;
/** A Set of EntityKey containing collected depending entities during dependency collection and processing phase */
final Set<EntityKey> dependencies;
/** A Set of EntityKey containing collected bulk action processing results during processing phase */
final Set<Result<EntityKey>> result;
/** Indicates if this BulkAction has already been processed and is not valid anymore */
boolean alreadyProcessed = false;
public BulkAction(

View file

@ -8,176 +8,15 @@
package ch.ethz.seb.sebserver.webservice.servicelayer.bulkaction;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import ch.ethz.seb.sebserver.gbl.api.EntityType;
import ch.ethz.seb.sebserver.gbl.model.EntityKey;
import ch.ethz.seb.sebserver.gbl.model.EntityProcessingReport;
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.UserActivityLogDAO;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.UserActivityLogDAO.ActivityType;
@Service
@WebServiceProfile
public class BulkActionService {
public interface BulkActionService {
private final Map<EntityType, BulkActionSupportDAO<?>> supporter;
private final UserActivityLogDAO userActivityLogDAO;
void collectDependencies(BulkAction action);
public BulkActionService(
final Collection<BulkActionSupportDAO<?>> supporter,
final UserActivityLogDAO userActivityLogDAO) {
Result<BulkAction> doBulkAction(BulkAction action);
this.supporter = new HashMap<>();
for (final BulkActionSupportDAO<?> support : supporter) {
this.supporter.put(support.entityType(), support);
}
this.userActivityLogDAO = userActivityLogDAO;
}
Result<EntityProcessingReport> createReport(BulkAction action);
public void collectDependencies(final BulkAction action) {
checkProcessing(action);
for (final BulkActionSupportDAO<?> sup : this.supporter.values()) {
action.dependencies.addAll(sup.getDependencies(action));
}
action.alreadyProcessed = true;
}
public Result<BulkAction> doBulkAction(final BulkAction action) {
return Result.tryCatch(() -> {
checkProcessing(action);
final BulkActionSupportDAO<?> supportForSource = this.supporter
.get(action.sourceType);
if (supportForSource == null) {
action.alreadyProcessed = true;
throw new IllegalArgumentException("No bulk action support for: " + action);
}
collectDependencies(action);
if (!action.dependencies.isEmpty()) {
// process dependencies first...
final List<BulkActionSupportDAO<?>> dependancySupporter =
getDependancySupporter(action);
for (final BulkActionSupportDAO<?> support : dependancySupporter) {
action.result.addAll(support.processBulkAction(action));
}
}
action.result.addAll(supportForSource.processBulkAction(action));
processUserActivityLog(action);
action.alreadyProcessed = true;
return action;
});
}
public Result<EntityProcessingReport> createReport(final BulkAction action) {
if (!action.alreadyProcessed) {
return doBulkAction(action)
.flatMap(this::createFullReport);
} else {
return createFullReport(action);
}
}
private Result<EntityProcessingReport> createFullReport(final BulkAction action) {
return Result.tryCatch(() -> {
// TODO
return new EntityProcessingReport(
action.sources,
Collections.emptyList(),
Collections.emptyList());
});
}
private void processUserActivityLog(final BulkAction action) {
final ActivityType activityType = action.getActivityType();
if (activityType == null) {
return;
}
for (final EntityKey key : action.dependencies) {
this.userActivityLogDAO.log(
activityType,
key.entityType,
key.modelId,
"bulk action dependency");
}
for (final EntityKey key : action.sources) {
this.userActivityLogDAO.log(
activityType,
key.entityType,
key.modelId,
"bulk action source");
}
}
private List<BulkActionSupportDAO<?>> getDependancySupporter(final BulkAction action) {
switch (action.type) {
case ACTIVATE:
case DEACTIVATE:
case HARD_DELETE: {
final List<BulkActionSupportDAO<?>> dependantSupporterInHierarchicalOrder =
getDependantSupporterInHierarchicalOrder(action);
Collections.reverse(dependantSupporterInHierarchicalOrder);
return dependantSupporterInHierarchicalOrder
.stream()
.filter(v -> v != null)
.collect(Collectors.toList());
}
default:
return getDependantSupporterInHierarchicalOrder(action);
}
}
private List<BulkActionSupportDAO<?>> getDependantSupporterInHierarchicalOrder(final BulkAction action) {
switch (action.sourceType) {
case INSTITUTION:
return Arrays.asList(
this.supporter.get(EntityType.LMS_SETUP),
this.supporter.get(EntityType.USER),
this.supporter.get(EntityType.EXAM),
this.supporter.get(EntityType.INDICATOR),
this.supporter.get(EntityType.CLIENT_CONNECTION),
this.supporter.get(EntityType.CONFIGURATION_NODE));
case USER:
return Arrays.asList(
this.supporter.get(EntityType.EXAM),
this.supporter.get(EntityType.INDICATOR),
this.supporter.get(EntityType.CLIENT_CONNECTION),
this.supporter.get(EntityType.CONFIGURATION_NODE));
case LMS_SETUP:
case EXAM:
case CONFIGURATION:
return Arrays.asList(
this.supporter.get(EntityType.EXAM),
this.supporter.get(EntityType.INDICATOR),
this.supporter.get(EntityType.CLIENT_CONNECTION));
default:
return Collections.emptyList();
}
}
private void checkProcessing(final BulkAction action) {
if (action.alreadyProcessed) {
throw new IllegalStateException("Given BulkAction has already been processed. Use a new one");
}
}
}
}

View file

@ -0,0 +1,186 @@
/*
* 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.bulkaction;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import ch.ethz.seb.sebserver.gbl.api.EntityType;
import ch.ethz.seb.sebserver.gbl.model.EntityKey;
import ch.ethz.seb.sebserver.gbl.model.EntityProcessingReport;
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.UserActivityLogDAO;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.UserActivityLogDAO.ActivityType;
@Service
@WebServiceProfile
public class BulkActionServiceImpl implements BulkActionService {
private final Map<EntityType, BulkActionSupportDAO<?>> supporter;
private final UserActivityLogDAO userActivityLogDAO;
public BulkActionServiceImpl(
final Collection<BulkActionSupportDAO<?>> supporter,
final UserActivityLogDAO userActivityLogDAO) {
this.supporter = new HashMap<>();
for (final BulkActionSupportDAO<?> support : supporter) {
this.supporter.put(support.entityType(), support);
}
this.userActivityLogDAO = userActivityLogDAO;
}
@Override
public void collectDependencies(final BulkAction action) {
checkProcessing(action);
for (final BulkActionSupportDAO<?> sup : this.supporter.values()) {
action.dependencies.addAll(sup.getDependencies(action));
}
action.alreadyProcessed = true;
}
@Override
public Result<BulkAction> doBulkAction(final BulkAction action) {
return Result.tryCatch(() -> {
checkProcessing(action);
final BulkActionSupportDAO<?> supportForSource = this.supporter
.get(action.sourceType);
if (supportForSource == null) {
action.alreadyProcessed = true;
throw new IllegalArgumentException("No bulk action support for: " + action);
}
collectDependencies(action);
if (!action.dependencies.isEmpty()) {
// process dependencies first...
final List<BulkActionSupportDAO<?>> dependancySupporter =
getDependancySupporter(action);
for (final BulkActionSupportDAO<?> support : dependancySupporter) {
action.result.addAll(support.processBulkAction(action));
}
}
action.result.addAll(supportForSource.processBulkAction(action));
processUserActivityLog(action);
action.alreadyProcessed = true;
return action;
});
}
@Override
public Result<EntityProcessingReport> createReport(final BulkAction action) {
if (!action.alreadyProcessed) {
return doBulkAction(action)
.flatMap(this::createFullReport);
} else {
return createFullReport(action);
}
}
private Result<EntityProcessingReport> createFullReport(final BulkAction action) {
return Result.tryCatch(() -> {
// TODO
return new EntityProcessingReport(
action.sources,
Collections.emptyList(),
Collections.emptyList());
});
}
private void processUserActivityLog(final BulkAction action) {
final ActivityType activityType = action.getActivityType();
if (activityType == null) {
return;
}
for (final EntityKey key : action.dependencies) {
this.userActivityLogDAO.log(
activityType,
key.entityType,
key.modelId,
"bulk action dependency");
}
for (final EntityKey key : action.sources) {
this.userActivityLogDAO.log(
activityType,
key.entityType,
key.modelId,
"bulk action source");
}
}
private List<BulkActionSupportDAO<?>> getDependancySupporter(final BulkAction action) {
switch (action.type) {
case ACTIVATE:
case DEACTIVATE:
case HARD_DELETE: {
final List<BulkActionSupportDAO<?>> dependantSupporterInHierarchicalOrder =
getDependantSupporterInHierarchicalOrder(action);
Collections.reverse(dependantSupporterInHierarchicalOrder);
return dependantSupporterInHierarchicalOrder
.stream()
.filter(v -> v != null)
.collect(Collectors.toList());
}
default:
return getDependantSupporterInHierarchicalOrder(action);
}
}
private List<BulkActionSupportDAO<?>> getDependantSupporterInHierarchicalOrder(final BulkAction action) {
switch (action.sourceType) {
case INSTITUTION:
return Arrays.asList(
this.supporter.get(EntityType.LMS_SETUP),
this.supporter.get(EntityType.USER),
this.supporter.get(EntityType.EXAM),
this.supporter.get(EntityType.INDICATOR),
this.supporter.get(EntityType.CLIENT_CONNECTION),
this.supporter.get(EntityType.CONFIGURATION_NODE));
case USER:
return Arrays.asList(
this.supporter.get(EntityType.EXAM),
this.supporter.get(EntityType.INDICATOR),
this.supporter.get(EntityType.CLIENT_CONNECTION),
this.supporter.get(EntityType.CONFIGURATION_NODE));
case LMS_SETUP:
case EXAM:
case CONFIGURATION:
return Arrays.asList(
this.supporter.get(EntityType.EXAM),
this.supporter.get(EntityType.INDICATOR),
this.supporter.get(EntityType.CLIENT_CONNECTION));
default:
return Collections.emptyList();
}
}
private void checkProcessing(final BulkAction action) {
if (action.alreadyProcessed) {
throw new IllegalStateException("Given BulkAction has already been processed. Use a new one");
}
}
}

View file

@ -25,6 +25,10 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ActivatableEntityDAO;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.DAOLoggingSupport;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.EntityDAO;
/** Defines overall DAO support for bulk-actions like activate, deactivate, delete...
*
*
* @param <T> The type of the Entity of a concrete BulkActionSupportDAO */
public interface BulkActionSupportDAO<T extends Entity> {
/** Get the entity type for a concrete EntityDAO implementation.
@ -32,8 +36,21 @@ public interface BulkActionSupportDAO<T extends Entity> {
* @return The EntityType for a concrete EntityDAO implementation */
EntityType entityType();
/** Gets a Set of EntityKey for all dependent entities for a given BulkAction
* and the type of this BulkActionSupportDAO.
*
* @param bulkAction the BulkAction to get keys of dependencies for the concrete type of this BulkActionSupportDAO
* @return */
Set<EntityKey> getDependencies(BulkAction bulkAction);
/** This processed a given BulkAction for all entities of the concrete type of this BulkActionSupportDAO
* that are defined by this given BulkAction.
*
* This returns a Collection of EntityKey results of each Entity that has been processed.
* If there was an error for a particular Entity, the Result will have an error reference.
*
* @param bulkAction the BulkAction containing the source entity and all dependencies
* @return a Collection of EntityKey results of each Entity that has been processed. */
@Transactional
default Collection<Result<EntityKey>> processBulkAction(final BulkAction bulkAction) {
final Set<EntityKey> all = bulkAction.extractKeys(entityType());

View file

@ -8,178 +8,30 @@
package ch.ethz.seb.sebserver.webservice.servicelayer.client;
import java.io.UnsupportedEncodingException;
import java.security.SecureRandom;
import org.apache.commons.lang3.RandomStringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.env.Environment;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.security.crypto.encrypt.Encryptors;
import org.springframework.stereotype.Service;
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
import ch.ethz.seb.sebserver.gbl.util.Result;
@Lazy
@Service
@WebServiceProfile
public class ClientCredentialService {
public interface ClientCredentialService {
private static final Logger log = LoggerFactory.getLogger(ClientCredentialService.class);
Result<ClientCredentials> createGeneratedClientCredentials();
static final String SEBSERVER_WEBSERVICE_INTERNAL_SECRET_KEY = "sebserver.webservice.internalSecret";
static final CharSequence DEFAULT_SALT = "b7dbe99bbfa3e21e";
Result<ClientCredentials> createGeneratedClientCredentials(CharSequence salt);
private final Environment environment;
ClientCredentials encryptedClientCredentials(ClientCredentials clientCredentials);
protected ClientCredentialService(final Environment environment) {
this.environment = environment;
}
ClientCredentials encryptedClientCredentials(
ClientCredentials clientCredentials,
CharSequence salt);
public Result<ClientCredentials> createGeneratedClientCredentials() {
return createGeneratedClientCredentials(null);
}
CharSequence getPlainClientId(ClientCredentials credentials);
public Result<ClientCredentials> createGeneratedClientCredentials(final CharSequence salt) {
return Result.tryCatch(() -> {
CharSequence getPlainClientId(ClientCredentials credentials, CharSequence salt);
try {
return encryptedClientCredentials(
new ClientCredentials(
generateClientId().toString(),
generateClientSecret().toString(),
null),
salt);
} catch (final UnsupportedEncodingException e) {
log.error("Error while trying to generate client credentials: ", e);
throw new RuntimeException("cause: ", e);
}
});
}
CharSequence getPlainClientSecret(ClientCredentials credentials);
public ClientCredentials encryptedClientCredentials(final ClientCredentials clientCredentials) {
return encryptedClientCredentials(clientCredentials, null);
}
CharSequence getPlainClientSecret(ClientCredentials credentials, CharSequence salt);
public ClientCredentials encryptedClientCredentials(
final ClientCredentials clientCredentials,
final CharSequence salt) {
CharSequence getPlainAccessToken(ClientCredentials credentials);
final CharSequence secret = this.environment
.getRequiredProperty(SEBSERVER_WEBSERVICE_INTERNAL_SECRET_KEY);
CharSequence getPlainAccessToken(ClientCredentials credentials, CharSequence salt);
return new ClientCredentials(
(clientCredentials.clientId != null)
? encrypt(clientCredentials.clientId, secret, salt).toString()
: null,
(clientCredentials.secret != null)
? encrypt(clientCredentials.secret, secret, salt).toString()
: null,
(clientCredentials.accessToken != null)
? encrypt(clientCredentials.accessToken, secret, salt).toString()
: null);
}
public CharSequence getPlainClientId(final ClientCredentials credentials) {
return getPlainClientId(credentials, null);
}
public CharSequence getPlainClientId(final ClientCredentials credentials, final CharSequence salt) {
if (credentials == null || credentials.clientId == null) {
return null;
}
final CharSequence secret = this.environment
.getRequiredProperty(SEBSERVER_WEBSERVICE_INTERNAL_SECRET_KEY);
return this.decrypt(credentials.clientId, secret, salt);
}
public CharSequence getPlainClientSecret(final ClientCredentials credentials) {
return getPlainClientSecret(credentials, null);
}
public CharSequence getPlainClientSecret(final ClientCredentials credentials, final CharSequence salt) {
if (credentials == null || credentials.secret == null) {
return null;
}
final CharSequence secret = this.environment
.getRequiredProperty(SEBSERVER_WEBSERVICE_INTERNAL_SECRET_KEY);
return this.decrypt(credentials.secret, secret, salt);
}
public CharSequence getPlainAccessToken(final ClientCredentials credentials) {
return getPlainAccessToken(credentials, null);
}
public CharSequence getPlainAccessToken(final ClientCredentials credentials, final CharSequence salt) {
if (credentials == null || credentials.accessToken == null) {
return null;
}
final CharSequence secret = this.environment
.getRequiredProperty(SEBSERVER_WEBSERVICE_INTERNAL_SECRET_KEY);
return this.decrypt(credentials.accessToken, secret, salt);
}
CharSequence encrypt(final CharSequence text, final CharSequence secret, final CharSequence salt) {
if (text == null) {
throw new IllegalArgumentException("Text has null reference");
}
try {
return Encryptors
.delux(secret, getSalt(salt))
.encrypt(text.toString());
} catch (final Exception e) {
log.error("Failed to encrypt text: ", e);
return text;
}
}
CharSequence decrypt(final CharSequence text, final CharSequence secret, final CharSequence salt) {
if (text == null) {
throw new IllegalArgumentException("Text has null reference");
}
try {
return Encryptors
.delux(secret, getSalt(salt))
.decrypt(text.toString());
} catch (final Exception e) {
log.error("Failed to decrypt text: ", e);
return text;
}
}
private final static char[] possibleCharacters = (new String(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~`!@#$%^&*()-_=+[{]}?"))
.toCharArray();
private CharSequence getSalt(final CharSequence saltPlain) throws UnsupportedEncodingException {
final CharSequence _salt = (saltPlain == null || saltPlain.length() <= 0)
? this.environment.getProperty(SEBSERVER_WEBSERVICE_INTERNAL_SECRET_KEY, DEFAULT_SALT.toString())
: saltPlain;
return new String(Hex.encode(_salt.toString().getBytes("UTF-8")));
}
private CharSequence generateClientId() {
return RandomStringUtils.random(
16, 0, possibleCharacters.length - 1, false, false,
possibleCharacters, new SecureRandom());
}
private CharSequence generateClientSecret() throws UnsupportedEncodingException {
return RandomStringUtils.random(
64, 0, possibleCharacters.length - 1, false, false,
possibleCharacters, new SecureRandom());
}
}
}

View file

@ -0,0 +1,195 @@
/*
* 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.client;
import java.io.UnsupportedEncodingException;
import java.security.SecureRandom;
import org.apache.commons.lang3.RandomStringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.env.Environment;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.security.crypto.encrypt.Encryptors;
import org.springframework.stereotype.Service;
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
import ch.ethz.seb.sebserver.gbl.util.Result;
@Lazy
@Service
@WebServiceProfile
public class ClientCredentialServiceImpl implements ClientCredentialService {
private static final Logger log = LoggerFactory.getLogger(ClientCredentialServiceImpl.class);
static final String SEBSERVER_WEBSERVICE_INTERNAL_SECRET_KEY = "sebserver.webservice.internalSecret";
static final CharSequence DEFAULT_SALT = "b7dbe99bbfa3e21e";
private final Environment environment;
protected ClientCredentialServiceImpl(final Environment environment) {
this.environment = environment;
}
@Override
public Result<ClientCredentials> createGeneratedClientCredentials() {
return createGeneratedClientCredentials(null);
}
@Override
public Result<ClientCredentials> createGeneratedClientCredentials(final CharSequence salt) {
return Result.tryCatch(() -> {
try {
return encryptedClientCredentials(
new ClientCredentials(
generateClientId().toString(),
generateClientSecret().toString(),
null),
salt);
} catch (final UnsupportedEncodingException e) {
log.error("Error while trying to generate client credentials: ", e);
throw new RuntimeException("cause: ", e);
}
});
}
@Override
public ClientCredentials encryptedClientCredentials(final ClientCredentials clientCredentials) {
return encryptedClientCredentials(clientCredentials, null);
}
@Override
public ClientCredentials encryptedClientCredentials(
final ClientCredentials clientCredentials,
final CharSequence salt) {
final CharSequence secret = this.environment
.getRequiredProperty(SEBSERVER_WEBSERVICE_INTERNAL_SECRET_KEY);
return new ClientCredentials(
(clientCredentials.clientId != null)
? encrypt(clientCredentials.clientId, secret, salt).toString()
: null,
(clientCredentials.secret != null)
? encrypt(clientCredentials.secret, secret, salt).toString()
: null,
(clientCredentials.accessToken != null)
? encrypt(clientCredentials.accessToken, secret, salt).toString()
: null);
}
@Override
public CharSequence getPlainClientId(final ClientCredentials credentials) {
return getPlainClientId(credentials, null);
}
@Override
public CharSequence getPlainClientId(final ClientCredentials credentials, final CharSequence salt) {
if (credentials == null || credentials.clientId == null) {
return null;
}
final CharSequence secret = this.environment
.getRequiredProperty(SEBSERVER_WEBSERVICE_INTERNAL_SECRET_KEY);
return this.decrypt(credentials.clientId, secret, salt);
}
@Override
public CharSequence getPlainClientSecret(final ClientCredentials credentials) {
return getPlainClientSecret(credentials, null);
}
@Override
public CharSequence getPlainClientSecret(final ClientCredentials credentials, final CharSequence salt) {
if (credentials == null || credentials.secret == null) {
return null;
}
final CharSequence secret = this.environment
.getRequiredProperty(SEBSERVER_WEBSERVICE_INTERNAL_SECRET_KEY);
return this.decrypt(credentials.secret, secret, salt);
}
@Override
public CharSequence getPlainAccessToken(final ClientCredentials credentials) {
return getPlainAccessToken(credentials, null);
}
@Override
public CharSequence getPlainAccessToken(final ClientCredentials credentials, final CharSequence salt) {
if (credentials == null || credentials.accessToken == null) {
return null;
}
final CharSequence secret = this.environment
.getRequiredProperty(SEBSERVER_WEBSERVICE_INTERNAL_SECRET_KEY);
return this.decrypt(credentials.accessToken, secret, salt);
}
CharSequence encrypt(final CharSequence text, final CharSequence secret, final CharSequence salt) {
if (text == null) {
throw new IllegalArgumentException("Text has null reference");
}
try {
return Encryptors
.delux(secret, getSalt(salt))
.encrypt(text.toString());
} catch (final Exception e) {
log.error("Failed to encrypt text: ", e);
return text;
}
}
CharSequence decrypt(final CharSequence text, final CharSequence secret, final CharSequence salt) {
if (text == null) {
throw new IllegalArgumentException("Text has null reference");
}
try {
return Encryptors
.delux(secret, getSalt(salt))
.decrypt(text.toString());
} catch (final Exception e) {
log.error("Failed to decrypt text: ", e);
return text;
}
}
private final static char[] possibleCharacters = (new String(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~`!@#$%^&*()-_=+[{]}?"))
.toCharArray();
private CharSequence getSalt(final CharSequence saltPlain) throws UnsupportedEncodingException {
final CharSequence _salt = (saltPlain == null || saltPlain.length() <= 0)
? this.environment.getProperty(SEBSERVER_WEBSERVICE_INTERNAL_SECRET_KEY, DEFAULT_SALT.toString())
: saltPlain;
return new String(Hex.encode(_salt.toString().getBytes("UTF-8")));
}
private CharSequence generateClientId() {
return RandomStringUtils.random(
16, 0, possibleCharacters.length - 1, false, false,
possibleCharacters, new SecureRandom());
}
private CharSequence generateClientSecret() throws UnsupportedEncodingException {
return RandomStringUtils.random(
64, 0, possibleCharacters.length - 1, false, false,
possibleCharacters, new SecureRandom());
}
}

View file

@ -15,6 +15,7 @@ import org.slf4j.LoggerFactory;
import ch.ethz.seb.sebserver.gbl.util.Result;
/** Adds some static logging support for DAO's */
public final class DAOLoggingSupport {
public static final Logger log = LoggerFactory.getLogger(DAOLoggingSupport.class);

View file

@ -13,8 +13,10 @@ import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import ch.ethz.seb.sebserver.gbl.api.POSTMapper;
import ch.ethz.seb.sebserver.gbl.model.Entity;
import ch.ethz.seb.sebserver.gbl.model.exam.Exam;
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.LmsSetup;
import ch.ethz.seb.sebserver.gbl.model.institution.SebClientConfig;
import ch.ethz.seb.sebserver.gbl.model.user.UserInfo;
@ -39,7 +41,7 @@ public class FilterMap extends POSTMapper {
}
public String getName() {
return getSQLWildcard(UserInfo.FILTER_ATTR_NAME);
return getSQLWildcard(Entity.FILTER_ATTR_NAME);
}
public String getUserUsername() {
@ -54,14 +56,14 @@ public class FilterMap extends POSTMapper {
return getString(UserInfo.FILTER_ATTR_LANGUAGE);
}
public String getLmsSetupName() {
return getSQLWildcard(LmsSetup.FILTER_ATTR_NAME);
}
public String getLmsSetupType() {
return getString(LmsSetup.FILTER_ATTR_LMS_TYPE);
}
public DateTime getQuizFromTime() {
return JodaTimeTypeResolver.getDateTime(getString(QuizData.FILTER_ATTR_START_TIME));
}
public DateTime getExamFromTime() {
return JodaTimeTypeResolver.getDateTime(getString(Exam.FILTER_ATTR_FROM));
}
@ -74,8 +76,8 @@ public class FilterMap extends POSTMapper {
return getString(Exam.FILTER_ATTR_QUIZ_ID);
}
public Long getExamLmsSetupId() {
return getLong(Exam.FILTER_ATTR_LMS_SETUP);
public Long getLmsSetupId() {
return getLong(LmsSetup.FILTER_ATTR_LMS_SETUP);
}
public String getExamStatus() {

View file

@ -134,7 +134,7 @@ public class ExamDAOImpl implements ExamDAO {
isEqualToWhenPresent(filterMap.getExamQuizId()))
.and(
ExamRecordDynamicSqlSupport.lmsSetupId,
isEqualToWhenPresent(filterMap.getExamLmsSetupId()))
isEqualToWhenPresent(filterMap.getLmsSetupId()))
.and(
ExamRecordDynamicSqlSupport.status,
isEqualToWhenPresent(filterMap.getExamStatus()))
@ -366,7 +366,7 @@ public class ExamDAOImpl implements ExamDAO {
(map1, map2) -> Utils.mapPutAll(map1, map2));
return this.lmsAPIService
.createLmsAPITemplate(lmsSetupId)
.getLmsAPITemplate(lmsSetupId)
.map(template -> template.getQuizzes(recordMapping.keySet()))
.getOrThrow()
.stream()

View file

@ -110,7 +110,7 @@ public class LmsSetupDAOImpl implements LmsSetupDAO {
isEqualToWhenPresent(filterMap.getInstitutionId()))
.and(
LmsSetupRecordDynamicSqlSupport.name,
isLikeWhenPresent(filterMap.getLmsSetupName()))
isLikeWhenPresent(filterMap.getName()))
.and(
LmsSetupRecordDynamicSqlSupport.lmsType,
isEqualToWhenPresent(filterMap.getLmsSetupType()))

View file

@ -8,13 +8,84 @@
package ch.ethz.seb.sebserver.webservice.servicelayer.lms;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup;
import ch.ethz.seb.sebserver.gbl.util.Result;
import java.util.List;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime;
import ch.ethz.seb.sebserver.gbl.model.Page;
import ch.ethz.seb.sebserver.gbl.model.exam.QuizData;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap;
/** Defines the LMS API access service interface with all functionality needed to access
* a LMS API within a given LmsSetup configuration.
*
* There are LmsAPITemplate implementations for each type of supported LMS that are managed
* in reference to a LmsSetup configuration within this service. This means actually that
* this service caches requested LmsAPITemplate (that holds the LMS API connection) as long
* as there is no change in the underling LmsSetup configuration. If the LmsSetup configuration
* changes this service will be notifies about the change and release the related LmsAPITemplate from cache. */
public interface LmsAPIService {
Result<LmsAPITemplate> createLmsAPITemplate(Long lmsSetupId);
Result<Page<QuizData>> requestQuizDataPage(
final int pageNumber,
final int pageSize,
final String sort,
final FilterMap filterMap);
Result<LmsAPITemplate> createLmsAPITemplate(LmsSetup lmsSetup);
/** Get a LmsAPITemplate for specified LmsSetup configuration.
*
* @param lmsSetupId the identifier of LmsSetup
* @return LmsAPITemplate for specified LmsSetup configuration */
Result<LmsAPITemplate> getLmsAPITemplate(String lmsSetupId);
default Result<LmsAPITemplate> getLmsAPITemplate(final Long lmsSetupId) {
if (lmsSetupId == null) {
return Result.ofError(new IllegalArgumentException("lmsSetupId has null-reference"));
}
return getLmsAPITemplate(String.valueOf(lmsSetupId));
}
public static Predicate<QuizData> quizzeFilterFunction(final FilterMap filterMap) {
final String name = filterMap.getName();
final DateTime from = filterMap.getQuizFromTime();
return q -> (StringUtils.isBlank(name) || (q.name != null && q.name.contains(name)))
&& (from == null) || (q.startTime != null && q.startTime.isBefore(from));
}
public static Function<List<QuizData>, List<QuizData>> quizzesFilterFunction(final FilterMap filterMap) {
filterMap.getName();
return quizzes -> quizzes
.stream()
.filter(quizzeFilterFunction(filterMap))
.collect(Collectors.toList());
}
public static Function<List<QuizData>, Page<QuizData>> quizzesToPageFunction(
final String sort,
final int pageNumber,
final int pageSize) {
return quizzes -> {
final int start = pageNumber * pageSize;
int end = start + pageSize;
if (end > quizzes.size() - 1) {
end = quizzes.size() - 1;
}
return new Page<>(quizzes.size() / pageSize, pageNumber, sort, quizzes.subList(start, end));
};
}
public static Function<List<QuizData>, List<QuizData>> quizzesSortFunction(final String sort) {
return quizzes -> {
quizzes.sort(QuizData.getComparator(sort));
return quizzes;
};
}
}

View file

@ -8,33 +8,77 @@
package ch.ethz.seb.sebserver.webservice.servicelayer.lms;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import ch.ethz.seb.sebserver.gbl.model.Page;
import org.apache.commons.lang3.StringUtils;
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.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.model.user.ExamineeAccountDetails;
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;
/** Defines the interface to an LMS within a specified LMSSetup configuration.
* There is one concrete implementations for every supported type of LMS like
* Open edX or Moodle
*
* A LmsAPITemplate defines at least the core API access to query courses and quizzes from the LMS
* Later a concrete LmsAPITemplate may also implement some special features regarding to the type
* of LMS */
public interface LmsAPITemplate {
Result<LmsSetup> lmsSetup();
/** Get the underling LMSSetup configuration for this 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();
Result<Page<QuizData>> getQuizzes(
String name,
Long from,
String sort,
int pageNumber,
int pageSize);
/** Get a Result of an unsorted List of filtered QuizData from the LMS course/quiz API
*
* @param filterMap the FilterMap to get a filtered result. For possible filter attributes
* see documentation on QuizData
* @return Result of an unsorted List of filtered QuizData from the LMS course/quiz API
* or refer to an error when happened */
Result<List<QuizData>> getQuizzes(FilterMap filterMap);
Collection<Result<QuizData>> getQuizzes(Set<String> ids);
Result<ExamineeAccountDetails> getExamineeAccountDetails(String examineeUserId);
void reset();
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 (StringUtils.isBlank(credentials.clientId)) {
missingAttrs.add(APIMessage.fieldValidationError(
LMS_SETUP.ATTR_LMS_CLIENTNAME,
"lmsSetup:lmsClientname:notNull"));
}
if (StringUtils.isBlank(credentials.secret)) {
missingAttrs.add(APIMessage.fieldValidationError(
LMS_SETUP.ATTR_LMS_CLIENTSECRET,
"lmsSetup:lmsClientsecret:notNull"));
}
return missingAttrs;
}
}

View file

@ -8,39 +8,57 @@
package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl;
import java.util.ArrayList;
import java.util.List;
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.http.client.ClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import ch.ethz.seb.sebserver.gbl.Constants;
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;
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
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;
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.weblayer.api.IllegalAPIArgumentException;
@Lazy
@Service
@WebServiceProfile
public class LmsAPIServiceImpl implements LmsAPIService {
private static final Logger log = LoggerFactory.getLogger(LmsAPIServiceImpl.class);
private final LmsSetupDAO lmsSetupDAO;
private final ClientCredentialService internalEncryptionService;
private final ClientCredentialService clientCredentialService;
private final ClientHttpRequestFactory clientHttpRequestFactory;
private final String[] openEdxAlternativeTokenRequestPaths;
// TODO internal caching of LmsAPITemplate per LmsSetup (Id)
private final Map<CacheKey, LmsAPITemplate> cache = new ConcurrentHashMap<>();
public LmsAPIServiceImpl(
final LmsSetupDAO lmsSetupDAO,
final ClientCredentialService internalEncryptionService,
final ClientCredentialService clientCredentialService,
final ClientHttpRequestFactory clientHttpRequestFactory,
@Value("${sebserver.lms.openedix.api.token.request.paths}") final String alternativeTokenRequestPaths) {
this.lmsSetupDAO = lmsSetupDAO;
this.internalEncryptionService = internalEncryptionService;
this.clientCredentialService = clientCredentialService;
this.clientHttpRequestFactory = clientHttpRequestFactory;
this.openEdxAlternativeTokenRequestPaths = (alternativeTokenRequestPaths != null)
@ -48,60 +66,167 @@ public class LmsAPIServiceImpl implements LmsAPIService {
: null;
}
@Override
public Result<LmsAPITemplate> createLmsAPITemplate(final Long lmsSetupId) {
return this.lmsSetupDAO
.byPK(lmsSetupId)
.flatMap(this::createLmsAPITemplate);
}
@Override
public Result<LmsAPITemplate> createLmsAPITemplate(final LmsSetup lmsSetup) {
switch (lmsSetup.lmsType) {
case MOCKUP:
return Result.of(new MockupLmsAPITemplate(
this.lmsSetupDAO,
lmsSetup,
this.internalEncryptionService));
case OPEN_EDX:
return Result.of(new OpenEdxLmsAPITemplate(
lmsSetup.getModelId(),
this.lmsSetupDAO,
this.internalEncryptionService,
this.clientHttpRequestFactory,
this.openEdxAlternativeTokenRequestPaths));
default:
return Result.ofError(
new UnsupportedOperationException("No support for LMS Type: " + lmsSetup.lmsType));
/** Listen to LmsSetupChangeEvent to release an affected LmsAPITemplate from cache
*
* @param event the event holding the changed LmsSetup */
@EventListener
public void notifyLmsSetupChange(final LmsSetupChangeEvent event) {
final LmsSetup lmsSetup = event.getLmsSetup();
if (lmsSetup == null) {
return;
}
log.debug("LmsSetup changed. Update cache by removeing eventually used references");
this.cache.remove(new CacheKey(lmsSetup.getModelId(), 0));
}
// @Override
// public Result<InputStream> createSEBStartConfiguration(final Long lmsSetupId) {
// return this.lmsSetupDAO
// .byPK(lmsSetupId)
// .flatMap(this::createSEBStartConfiguration);
// }
//
// @Override
// public Result<InputStream> createSEBStartConfiguration(final LmsSetup lmsSetup) {
//
// // TODO implementation of creation of SEB start configuration for specified LmsSetup
// // A SEB start configuration should at least contain the SEB-Client-Credentials to access the SEB Server API
// // and the SEB Server URL
// //
// // To Clarify : The format of a SEB start configuration
// // To Clarify : How the file should be encrypted (use case) maybe we need another encryption-secret for this that can be given by
// // an administrator on SEB start configuration creation time
//
// return Result.tryCatch(() -> {
// try {
// return new ByteArrayInputStream("TODO".getBytes("UTF-8"));
// } catch (final UnsupportedEncodingException e) {
// throw new RuntimeException("cause: ", e);
// }
// });
// }
@Override
public Result<Page<QuizData>> requestQuizDataPage(
final int pageNumber,
final int pageSize,
final String sort,
final FilterMap filterMap) {
return getAllQuizzesFromLMSSetups(filterMap)
.map(LmsAPIService.quizzesSortFunction(sort))
.map(LmsAPIService.quizzesToPageFunction(sort, pageNumber, pageSize));
}
/** Collect all QuizData from all affecting LmsSetup.
* If filterMap contains a LmsSetup identifier, only the QuizData from that LmsSetup is collected.
* Otherwise QuizData from all active LmsSetup of the current institution are collected.
*
* @param filterMap the FilterMap containing either an LmsSetup identifier or an institution identifier
* @return list of QuizData from all affecting LmsSetup */
private Result<List<QuizData>> getAllQuizzesFromLMSSetups(final FilterMap filterMap) {
return Result.tryCatch(() -> {
final Long lmsSetupId = filterMap.getLmsSetupId();
if (lmsSetupId != null) {
return getLmsAPITemplate(lmsSetupId)
.getOrThrow()
.getQuizzes(filterMap)
.getOrThrow();
}
final Long institutionId = filterMap.getInstitutionId();
if (institutionId == null) {
throw new IllegalAPIArgumentException("Missing institution identifier");
}
return this.lmsSetupDAO.all(institutionId, true)
.getOrThrow()
.stream()
.map(this::getLmsAPITemplate)
.flatMap(Result::onErrorLogAndSkip)
.map(template -> template.getQuizzes(filterMap))
.flatMap(Result::onErrorLogAndSkip)
.flatMap(List::stream)
.collect(Collectors.toList());
});
}
@Override
public Result<LmsAPITemplate> getLmsAPITemplate(final String lmsSetupId) {
log.debug("Get LmsAPITemplate for id: {}", lmsSetupId);
return Result.tryCatch(() -> {
return this.lmsSetupDAO
.byModelId(lmsSetupId)
.getOrThrow();
})
.flatMap(this::getLmsAPITemplate);
}
private Result<LmsAPITemplate> getLmsAPITemplate(final LmsSetup lmsSetup) {
return Result.tryCatch(() -> {
LmsAPITemplate lmsAPITemplate = getFromCache(lmsSetup);
if (lmsAPITemplate == null) {
log.debug("Get cached LmsAPITemplate with id: {}", lmsSetup.getModelId());
return lmsAPITemplate;
}
lmsAPITemplate = createLmsSetupTemplate(lmsSetup);
this.cache.put(new CacheKey(lmsSetup.getModelId(), System.currentTimeMillis()), lmsAPITemplate);
return lmsAPITemplate;
});
}
private LmsAPITemplate getFromCache(final LmsSetup lmsSetup) {
// first cleanup the cache by removing old instances
final long currentTimeMillis = System.currentTimeMillis();
new ArrayList<>(this.cache.keySet())
.stream()
.filter(key -> key.creationTimestamp - currentTimeMillis > Constants.DAY_IN_MILLIS)
.forEach(key -> this.cache.remove(key));
// get from cache
return this.cache.get(new CacheKey(lmsSetup.getModelId(), 0));
}
private LmsAPITemplate createLmsSetupTemplate(final LmsSetup lmsSetup) {
log.debug("Create new LmsAPITemplate for id: {}", lmsSetup.getModelId());
final ClientCredentials credentials = this.lmsSetupDAO
.getLmsAPIAccessCredentials(lmsSetup.getModelId())
.getOrThrow();
switch (lmsSetup.lmsType) {
case MOCKUP:
return new MockupLmsAPITemplate(
lmsSetup,
credentials,
this.clientCredentialService);
case OPEN_EDX:
return new OpenEdxLmsAPITemplate(
lmsSetup,
credentials,
this.clientCredentialService,
this.clientHttpRequestFactory,
this.openEdxAlternativeTokenRequestPaths);
default:
throw new UnsupportedOperationException("No support for LMS Type: " + lmsSetup.lmsType);
}
}
private static final class CacheKey {
final String lmsSetupId;
final long creationTimestamp;
final int hash;
CacheKey(final String lmsSetupId, final long creationTimestamp) {
this.lmsSetupId = lmsSetupId;
this.creationTimestamp = creationTimestamp;
final int prime = 31;
int result = 1;
result = prime * result + ((lmsSetupId == null) ? 0 : lmsSetupId.hashCode());
this.hash = result;
}
@Override
public int hashCode() {
return this.hash;
}
@Override
public boolean equals(final Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
final CacheKey other = (CacheKey) obj;
if (this.lmsSetupId == null) {
if (other.lmsSetupId != null)
return false;
} else if (!this.lmsSetupId.equals(other.lmsSetupId))
return false;
return true;
}
}
}

View file

@ -0,0 +1,27 @@
/*
* 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 org.springframework.context.ApplicationEvent;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup;
public class LmsSetupChangeEvent extends ApplicationEvent {
private static final long serialVersionUID = -7239994198026689531L;
public LmsSetupChangeEvent(final LmsSetup source) {
super(source);
}
public LmsSetup getLmsSetup() {
return (LmsSetup) this.source;
}
}

View file

@ -10,50 +10,46 @@ package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import ch.ethz.seb.sebserver.gbl.model.Domain.LMS_SETUP;
import ch.ethz.seb.sebserver.gbl.model.Page;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ch.ethz.seb.sebserver.gbl.api.APIMessage;
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.LmsSetup.LmsType;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult;
import ch.ethz.seb.sebserver.gbl.model.user.ExamineeAccountDetails;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.webservice.servicelayer.PaginationService.SortOrder;
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.LmsSetupDAO;
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;
final class MockupLmsAPITemplate implements LmsAPITemplate {
private static final Logger log = LoggerFactory.getLogger(MockupLmsAPITemplate.class);
public static final String MOCKUP_LMS_CLIENT_NAME = "mockupLmsClientName";
public static final String MOCKUP_LMS_CLIENT_SECRET = "mockupLmsClientSecret";
private final ClientCredentialService clientCredentialService;
private final LmsSetupDAO lmsSetupDao;
private final LmsSetup setup;
private ClientCredentials credentials = null;
private final LmsSetup lmsSetup;
private final ClientCredentials credentials;
private final Collection<QuizData> mockups;
MockupLmsAPITemplate(
final LmsSetupDAO lmsSetupDao,
final LmsSetup setup,
final LmsSetup lmsSetup,
final ClientCredentials credentials,
final ClientCredentialService clientCredentialService) {
this.lmsSetupDao = lmsSetupDao;
this.lmsSetup = lmsSetup;
this.clientCredentialService = clientCredentialService;
if (!setup.isActive() || setup.lmsType != LmsType.MOCKUP) {
throw new IllegalArgumentException();
}
this.credentials = credentials;
this.setup = setup;
this.mockups = new ArrayList<>();
this.mockups.add(new QuizData(
"quiz1", "Demo Quiz 1", "Demo Quit Mockup",
@ -79,88 +75,45 @@ final class MockupLmsAPITemplate implements LmsAPITemplate {
}
@Override
public Result<LmsSetup> lmsSetup() {
return Result.of(this.setup);
public LmsSetup lmsSetup() {
return this.lmsSetup;
}
@Override
public LmsSetupTestResult testLmsSetup() {
if (this.setup.lmsType != LmsType.MOCKUP) {
return LmsSetupTestResult.ofMissingAttributes(LMS_SETUP.ATTR_LMS_TYPE);
log.info("Test Lms Binding for Mockup and LmsSetup: {}", this.lmsSetup);
final List<APIMessage> missingAttrs = attributeValidation(this.credentials);
if (!missingAttrs.isEmpty()) {
return LmsSetupTestResult.ofMissingAttributes(missingAttrs);
}
initCredentials();
if (this.credentials != null) {
if (authenticate()) {
return LmsSetupTestResult.ofOkay();
} else {
return LmsSetupTestResult.ofMissingAttributes(
LMS_SETUP.ATTR_LMS_URL,
LMS_SETUP.ATTR_LMS_CLIENTNAME,
LMS_SETUP.ATTR_LMS_CLIENTSECRET);
return LmsSetupTestResult.ofTokenRequestError("Illegal access");
}
}
public Collection<QuizData> getQuizzes(
final String name,
final Long from,
final String sort) {
final int orderFactor = (SortOrder.getSortOrder(sort) == SortOrder.DESCENDING)
? -1
: 1;
final String _sort = SortOrder.decode(sort);
final Comparator<QuizData> comp = (_sort != null)
? (_sort.equals(QuizData.FILTER_ATTR_START_TIME))
? (q1, q2) -> q1.startTime.compareTo(q2.startTime) * orderFactor
: (q1, q2) -> q1.name.compareTo(q2.name) * orderFactor
: (q1, q2) -> q1.name.compareTo(q2.name) * orderFactor;
return this.mockups.stream()
.filter(mockup -> (name != null)
? mockup.name.contains(name)
: true && (from != null)
? mockup.startTime.getMillis() >= from
: true)
.sorted(comp)
.collect(Collectors.toList());
}
@Override
public Result<Page<QuizData>> getQuizzes(
final String name,
final Long from,
final String sort,
final int pageNumber,
final int pageSize) {
public Result<List<QuizData>> getQuizzes(final FilterMap filterMap) {
return Result.tryCatch(() -> {
initCredentials();
authenticate();
if (this.credentials == null) {
throw new IllegalArgumentException("Wrong clientId or secret");
}
final int startIndex = pageNumber * pageSize;
final int endIndex = startIndex + pageSize;
int index = 0;
final Collection<QuizData> quizzes = getQuizzes(name, from, sort);
final int numberOfPages = quizzes.size() / pageSize;
final Iterator<QuizData> iterator = quizzes.iterator();
final List<QuizData> pageContent = new ArrayList<>();
while (iterator.hasNext() && index < endIndex) {
final QuizData next = iterator.next();
if (index >= startIndex) {
pageContent.add(next);
}
index++;
}
return new Page<>(numberOfPages, pageNumber, sort, pageContent);
return this.mockups.stream()
.filter(LmsAPIService.quizzeFilterFunction(filterMap))
.collect(Collectors.toList());
});
}
@Override
public Collection<Result<QuizData>> getQuizzes(final Set<String> ids) {
initCredentials();
authenticate();
if (this.credentials == null) {
throw new IllegalArgumentException("Wrong clientId or secret");
}
@ -173,7 +126,7 @@ final class MockupLmsAPITemplate implements LmsAPITemplate {
@Override
public Result<ExamineeAccountDetails> getExamineeAccountDetails(final String examineeUserId) {
initCredentials();
authenticate();
if (this.credentials == null) {
throw new IllegalArgumentException("Wrong clientId or secret");
}
@ -181,28 +134,23 @@ final class MockupLmsAPITemplate implements LmsAPITemplate {
return Result.of(new ExamineeAccountDetails(examineeUserId, "mockup", "mockup", "mockup"));
}
@Override
public void reset() {
this.credentials = null;
}
private void initCredentials() {
private boolean authenticate() {
try {
this.credentials = this.lmsSetupDao
.getLmsAPIAccessCredentials(this.setup.getModelId())
.getOrThrow();
final CharSequence plainClientId = this.clientCredentialService.getPlainClientId(this.credentials);
if (!"lmsMockupClientId".equals(plainClientId)) {
throw new IllegalAccessError();
throw new IllegalAccessException("Wrong client credential");
}
final CharSequence plainClientSecret = this.clientCredentialService.getPlainClientSecret(this.credentials);
if (!"lmsMockupSecret".equals(plainClientSecret)) {
throw new IllegalAccessError();
throw new IllegalAccessException("Wrong client credential");
}
return true;
} catch (final Exception e) {
this.credentials = null;
log.info("Authentication failed: ", e);
return false;
}
}

View file

@ -22,25 +22,37 @@ 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.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.gbl.model.Domain.LMS_SETUP;
import ch.ethz.seb.sebserver.gbl.model.Page;
import ch.ethz.seb.sebserver.gbl.api.APIMessage;
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.LmsSetup.LmsType;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult;
import ch.ethz.seb.sebserver.gbl.model.user.ExamineeAccountDetails;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.gbl.util.SupplierWithCircuitBreaker;
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.LmsSetupDAO;
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;
/** 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);
@ -49,26 +61,26 @@ final class OpenEdxLmsAPITemplate implements LmsAPITemplate {
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 String lmsSetupId;
private final LmsSetupDAO lmsSetupDAO;
private final LmsSetup lmsSetup;
private final ClientCredentials credentials;
private final ClientHttpRequestFactory clientHttpRequestFactory;
private final ClientCredentialService clientCredentialService;
private final Set<String> knownTokenAccessPaths;
private OAuth2RestTemplate restTemplate = null;
private SupplierWithCircuitBreaker<List<QuizData>> allQuizzesSupplier = null;
OpenEdxLmsAPITemplate(
final String lmsSetupId,
final LmsSetupDAO lmsSetupDAO,
final LmsSetup lmsSetup,
final ClientCredentials credentials,
final ClientCredentialService clientCredentialService,
final ClientHttpRequestFactory clientHttpRequestFactory,
final String[] alternativeTokenRequestPaths) {
this.lmsSetupId = lmsSetupId;
this.lmsSetupDAO = lmsSetupDAO;
this.clientHttpRequestFactory = clientHttpRequestFactory;
this.lmsSetup = lmsSetup;
this.clientCredentialService = clientCredentialService;
this.credentials = credentials;
this.clientHttpRequestFactory = clientHttpRequestFactory;
this.knownTokenAccessPaths = new HashSet<>();
this.knownTokenAccessPaths.add(OPEN_EDX_DEFAULT_TOKEN_REQUEST_PATH);
if (alternativeTokenRequestPaths != null) {
@ -77,87 +89,45 @@ final class OpenEdxLmsAPITemplate implements LmsAPITemplate {
}
@Override
public Result<LmsSetup> lmsSetup() {
return this.lmsSetupDAO
.byModelId(this.lmsSetupId);
public LmsSetup lmsSetup() {
return this.lmsSetup;
}
@Override
public LmsSetupTestResult testLmsSetup() {
final LmsSetup lmsSetup = lmsSetup().getOrThrow();
log.info("Test Lms Binding for OpenEdX and LmsSetup: {}", lmsSetup);
// validation of LmsSetup
if (lmsSetup.lmsType != LmsType.MOCKUP) {
return LmsSetupTestResult.ofMissingAttributes(LMS_SETUP.ATTR_LMS_TYPE);
}
final List<String> missingAttrs = new ArrayList<>();
if (StringUtils.isBlank(lmsSetup.lmsApiUrl)) {
missingAttrs.add(LMS_SETUP.ATTR_LMS_TYPE);
}
if (StringUtils.isBlank(lmsSetup.getLmsAuthName())) {
missingAttrs.add(LMS_SETUP.ATTR_LMS_CLIENTNAME);
}
if (StringUtils.isBlank(lmsSetup.getLmsAuthSecret())) {
missingAttrs.add(LMS_SETUP.ATTR_LMS_CLIENTSECRET);
}
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(lmsSetup);
initRestTemplateAndRequestAccessToken();
if (this.restTemplate == null) {
return LmsSetupTestResult.ofTokenRequestError(
"Failed to gain access token form OpenEdX Rest API: tried token endpoints: " +
"Failed to gain access token from OpenEdX Rest API:\n tried token endpoints: " +
this.knownTokenAccessPaths);
}
// query quizzes TODO!?
return LmsSetupTestResult.ofOkay();
}
@Override
public Result<Page<QuizData>> getQuizzes(
final String name,
final Long from,
final String sort,
final int pageNumber,
final int pageSize) {
public Result<List<QuizData>> getQuizzes(final FilterMap filterMap) {
return this.initRestTemplateAndRequestAccessToken()
.flatMap(this::getAllQuizes)
.map(LmsAPIService.quizzesFilterFunction(filterMap));
}
return this.lmsSetup()
.flatMap(this::initRestTemplateAndRequestAccessToken)
.map(lmsSetup -> {
// TODO sort and pagination
final HttpHeaders httpHeaders = new HttpHeaders();
final ResponseEntity<EdXPage> response = this.restTemplate.exchange(
lmsSetup.lmsApiUrl + OPEN_EDX_DEFAULT_COURSE_ENDPOINT,
HttpMethod.GET,
new HttpEntity<>(httpHeaders),
EdXPage.class);
final EdXPage edxpage = response.getBody();
final List<QuizData> content = edxpage.results
.stream()
.reduce(
new ArrayList<QuizData>(),
(list, courseData) -> {
list.add(quizDataOf(lmsSetup, courseData));
return list;
},
(list1, list2) -> {
list1.addAll(list2);
return list1;
});
return new Page<>(edxpage.num_pages, pageNumber, sort, content);
});
public ResponseEntity<EdXPage> getEdxPage(final String pageURI) {
final HttpHeaders httpHeaders = new HttpHeaders();
return this.restTemplate.exchange(
pageURI,
HttpMethod.GET,
new HttpEntity<>(httpHeaders),
EdXPage.class);
}
@Override
@ -172,20 +142,15 @@ final class OpenEdxLmsAPITemplate implements LmsAPITemplate {
return null;
}
@Override
public void reset() {
this.restTemplate = null;
}
private Result<LmsSetup> initRestTemplateAndRequestAccessToken() {
private Result<LmsSetup> initRestTemplateAndRequestAccessToken(final LmsSetup lmsSetup) {
log.info("Initialize Rest Template for OpenEdX API access. LmsSetup: {}", lmsSetup);
log.info("Initialize Rest Template for OpenEdX API access. LmsSetup: {}", this.lmsSetup);
return Result.tryCatch(() -> {
if (this.restTemplate != null) {
try {
this.restTemplate.getAccessToken();
return lmsSetup;
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.",
@ -194,24 +159,20 @@ final class OpenEdxLmsAPITemplate implements LmsAPITemplate {
}
}
final ClientCredentials credentials = this.lmsSetupDAO
.getLmsAPIAccessCredentials(this.lmsSetupId)
.getOrThrow();
final Iterator<String> tokenAccessPaths = this.knownTokenAccessPaths.iterator();
while (tokenAccessPaths.hasNext()) {
final String accessTokenRequestPath = tokenAccessPaths.next();
try {
final OAuth2RestTemplate template = createRestTemplate(
lmsSetup,
credentials,
this.lmsSetup,
this.credentials,
accessTokenRequestPath);
final OAuth2AccessToken accessToken = template.getAccessToken();
if (accessToken != null) {
this.restTemplate = template;
return lmsSetup;
return this.lmsSetup;
}
} catch (final Exception e) {
log.info("Failed to request access token on access token request path: {}", accessTokenRequestPath,
@ -219,7 +180,8 @@ final class OpenEdxLmsAPITemplate implements LmsAPITemplate {
}
}
throw new IllegalArgumentException("Unable to establish OpenEdX API connection for lmsSetup: " + lmsSetup);
throw new IllegalArgumentException(
"Unable to establish OpenEdX API connection for lmsSetup: " + this.lmsSetup);
});
}
@ -235,17 +197,49 @@ final class OpenEdxLmsAPITemplate implements LmsAPITemplate {
details.setAccessTokenUri(lmsSetup.lmsApiUrl + accessTokenRequestPath);
details.setClientId(plainClientId.toString());
details.setClientSecret(plainClientSecret.toString());
details.setGrantType("client_credentials");
// TODO: accordingly to the documentation (https://course-catalog-api-guide.readthedocs.io/en/latest/authentication/#create-an-account-on-edx-org-for-api-access)
// token_type=jwt is needed for token request but is it possible to set this within ClientCredentialsResourceDetails
// or within the request header on API call. To clarify
final OAuth2RestTemplate template = new OAuth2RestTemplate(details);
template.setRequestFactory(this.clientHttpRequestFactory);
template.setAccessTokenProvider(new EdxClientCredentialsAccessTokenProvider());
return template;
}
private Result<List<QuizData>> getAllQuizes(final LmsSetup lmsSetup) {
if (this.allQuizzesSupplier == null) {
this.allQuizzesSupplier = new SupplierWithCircuitBreaker<>(
() -> collectAllCourses(lmsSetup.lmsApiUrl + OPEN_EDX_DEFAULT_COURSE_ENDPOINT)
.stream()
.reduce(
new ArrayList<QuizData>(),
(list, courseData) -> {
list.add(quizDataOf(lmsSetup, courseData));
return list;
},
(list1, list2) -> {
list1.addAll(list2);
return list1;
}),
5, 1000L); // TODO specify better CircuitBreaker params
}
return this.allQuizzesSupplier.get();
}
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 (StringUtils.isNoneBlank(page.next)) {
page = getEdxPage(page.next).getBody();
collector.addAll(page.results);
}
}
return collector;
}
private QuizData quizDataOf(
final LmsSetup lmsSetup,
final CourseData courseData) {
@ -260,14 +254,16 @@ final class OpenEdxLmsAPITemplate implements LmsAPITemplate {
startURI);
}
/** Maps a OpenEdX course API course page */
static final class EdXPage {
public Integer count;
public Integer previous;
public String previous;
public Integer num_pages;
public Integer next;
public String next;
public List<CourseData> results;
}
/** Maps the OpenEdX course API course data */
static final class CourseData {
public String id;
public String course_id;
@ -278,41 +274,31 @@ final class OpenEdxLmsAPITemplate implements LmsAPITemplate {
public String end;
}
/*
* pagination
* count 2
* previous null
* num_pages 1
* next null
* results
* 0
* blocks_url "http://ralph.ethz.ch:18000/api/courses/v1/blocks/?course_id=course-v1%3AedX%2BDemoX%2BDemo_Course"
* effort null
* end null
* enrollment_start null
* enrollment_end null
* id "course-v1:edX+DemoX+Demo_Course"
* media
* course_image
* uri "/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg"
* course_video
* uri null
* image
* raw "http://ralph.ethz.ch:18000/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg"
* small "http://ralph.ethz.ch:18000/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg"
* large "http://ralph.ethz.ch:18000/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg"
* name "edX Demonstration Course"
* number "DemoX"
* org "edX"
* short_description null
* start "2013-02-05T05:00:00Z"
* start_display "Feb. 5, 2013"
* start_type "timestamp"
* pacing "instructor"
* mobile_available false
* hidden false
* invitation_only false
* course_id "course-v1:edX+DemoX+Demo_Course"
*/
/** 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 {
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("token_type", "jwt");
params.add("client_id", resource.getClientId());
params.add("client_secret", resource.getClientSecret());
return retrieveToken(request, resource, params, headers);
}
}
}

View file

@ -137,7 +137,7 @@ public class APIExceptionHandler extends ResponseEntityExceptionHandler {
final WebRequest request) {
return new ResponseEntity<>(
Arrays.asList(ex.getAPIMessage()),
ex.getAPIMessages(),
HttpStatus.BAD_REQUEST);
}

View file

@ -253,7 +253,7 @@ public class ExamAdministrationController extends ActivatableEntityController<Ex
final String quizId = postParams.getString(QuizData.QUIZ_ATTR_ID);
final LmsAPITemplate lmsAPITemplate = this.lmsAPIService
.createLmsAPITemplate(lmsSetupId)
.getLmsAPITemplate(lmsSetupId)
.getOrThrow();
final QuizData quiz = lmsAPITemplate.getQuizzes(new HashSet<>(Arrays.asList(quizId)))

View file

@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import ch.ethz.seb.sebserver.gbl.api.API;
import ch.ethz.seb.sebserver.gbl.api.APIMessage.APIMessageException;
import ch.ethz.seb.sebserver.gbl.api.EntityType;
import ch.ethz.seb.sebserver.gbl.api.POSTMapper;
import ch.ethz.seb.sebserver.gbl.authorization.PrivilegeType;
@ -75,9 +76,15 @@ public class LmsSetupController extends ActivatableEntityController<LmsSetup, Lm
this.authorization.check(PrivilegeType.MODIFY, EntityType.LMS_SETUP);
return this.lmsAPIService.createLmsAPITemplate(modelId)
final LmsSetupTestResult result = this.lmsAPIService.getLmsAPITemplate(modelId)
.map(template -> template.testLmsSetup())
.getOrThrow();
if (result.missingLMSSetupAttribute != null && !result.missingLMSSetupAttribute.isEmpty()) {
throw new APIMessageException(result.missingLMSSetupAttribute);
}
return result;
}
@Override

View file

@ -9,6 +9,7 @@
package ch.ethz.seb.sebserver.webservice.weblayer.api;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
@ -17,16 +18,14 @@ import org.springframework.web.bind.annotation.RestController;
import ch.ethz.seb.sebserver.gbl.api.API;
import ch.ethz.seb.sebserver.gbl.api.EntityType;
import ch.ethz.seb.sebserver.gbl.authorization.PrivilegeType;
import ch.ethz.seb.sebserver.gbl.model.Domain.LMS_SETUP;
import ch.ethz.seb.sebserver.gbl.model.Entity;
import ch.ethz.seb.sebserver.gbl.model.Page;
import ch.ethz.seb.sebserver.gbl.model.exam.QuizData;
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
import ch.ethz.seb.sebserver.gbl.util.Utils;
import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.AuthorizationService;
import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.UserService;
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;
@WebServiceProfile
@RestController
@ -57,26 +56,20 @@ public class QuizImportController {
name = Entity.FILTER_ATTR_INSTITUTION,
required = true,
defaultValue = UserService.USERS_INSTITUTION_AS_DEFAULT) final Long institutionId,
@RequestParam(name = LMS_SETUP.ATTR_ID, required = true) final Long lmsSetupId,
@RequestParam(name = QuizData.FILTER_ATTR_NAME, required = false) final String nameLike,
@RequestParam(name = QuizData.FILTER_ATTR_START_TIME, required = false) final String startTime,
@RequestParam(name = Page.ATTR_PAGE_NUMBER, required = false) final Integer pageNumber,
@RequestParam(name = Page.ATTR_PAGE_SIZE, required = false) final Integer pageSize,
@RequestParam(name = Page.ATTR_SORT, required = false) final String sort) {
final LmsAPITemplate lmsAPITemplate = this.lmsAPIService
.createLmsAPITemplate(lmsSetupId)
.getOrThrow();
@RequestParam(name = Page.ATTR_SORT, required = false) final String sort,
@RequestParam final MultiValueMap<String, String> allRequestParams) {
this.authorization.check(
PrivilegeType.READ_ONLY,
EntityType.EXAM,
institutionId);
return lmsAPITemplate.getQuizzes(
nameLike,
Utils.dateTimeStringToTimestamp(startTime, null),
sort,
final FilterMap filterMap = new FilterMap(allRequestParams);
filterMap.putIfAbsent(Entity.FILTER_ATTR_INSTITUTION, String.valueOf(institutionId));
return this.lmsAPIService.requestQuizDataPage(
(pageNumber != null)
? pageNumber
: 1,
@ -84,7 +77,9 @@ public class QuizImportController {
? (pageSize <= this.maxPageSize)
? pageSize
: this.maxPageSize
: this.defaultPageSize)
: this.defaultPageSize,
sort,
filterMap)
.getOrThrow();
}

View file

@ -26,6 +26,7 @@ sebserver.form.validation.fieldError.notNull=This field is mandatory
sebserver.form.validation.fieldError.username.notunique=This Username is already in use. Please choose another one.
sebserver.form.validation.fieldError.password.wrong=Old password is wrong
sebserver.form.validation.fieldError.password.mismatch=Re-typed password don't match new password
sebserver.form.validation.fieldError.invalidURL=The input does not match the URL pattern.
sebserver.error.unexpected=Unexpected Error
sebserver.page.message=Information
sebserver.dialog.confirm.title=Confirmation
@ -159,6 +160,12 @@ sebserver.lmssetup.action.new=New LMS Setup
sebserver.lmssetup.action.list.view=View Selected
sebserver.lmssetup.action.list.modify=Edit Selected
sebserver.lmssetup.action.modify=Edit
sebserver.lmssetup.action.test=Test Setup
sebserver.lmssetup.action.test.ok=Successfully connect to the LMSs 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.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
sebserver.lmssetup.action.activate=Active
sebserver.lmssetup.action.deactivate=Active

View file

@ -3,10 +3,13 @@ SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0;
SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0;
SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='TRADITIONAL,ALLOW_INVALID_DATES';
-- -----------------------------------------------------
-- Schema SEBServer
-- -----------------------------------------------------
-- -----------------------------------------------------
-- Table `institution`
-- -----------------------------------------------------

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 B

View file

@ -32,23 +32,23 @@ public class ClientCredentialServiceTest {
@Test
public void testEncryptDecryptClientCredentials() {
final Environment envMock = mock(Environment.class);
when(envMock.getRequiredProperty(ClientCredentialService.SEBSERVER_WEBSERVICE_INTERNAL_SECRET_KEY))
when(envMock.getRequiredProperty(ClientCredentialServiceImpl.SEBSERVER_WEBSERVICE_INTERNAL_SECRET_KEY))
.thenReturn("secret1");
final String clientName = "simpleClientName";
final ClientCredentialService service = new ClientCredentialService(envMock);
final ClientCredentialServiceImpl service = new ClientCredentialServiceImpl(envMock);
String encrypted =
service.encrypt(clientName, "secret1", ClientCredentialService.DEFAULT_SALT).toString();
String decrypted = service.decrypt(encrypted, "secret1", ClientCredentialService.DEFAULT_SALT).toString();
service.encrypt(clientName, "secret1", ClientCredentialServiceImpl.DEFAULT_SALT).toString();
String decrypted = service.decrypt(encrypted, "secret1", ClientCredentialServiceImpl.DEFAULT_SALT).toString();
assertEquals(clientName, decrypted);
final String clientSecret = "fbjreij39ru29305ruࣣàèLöäöäü65%(/%(ç87";
encrypted =
service.encrypt(clientSecret, "secret1", ClientCredentialService.DEFAULT_SALT).toString();
decrypted = service.decrypt(encrypted, "secret1", ClientCredentialService.DEFAULT_SALT).toString();
service.encrypt(clientSecret, "secret1", ClientCredentialServiceImpl.DEFAULT_SALT).toString();
decrypted = service.decrypt(encrypted, "secret1", ClientCredentialServiceImpl.DEFAULT_SALT).toString();
assertEquals(clientSecret, decrypted);
}