From 7b2f7228afa88ab0f4706050338b7f29dff6f444 Mon Sep 17 00:00:00 2001 From: anhefti Date: Thu, 14 Mar 2019 13:32:26 +0100 Subject: [PATCH] SEBSERV-29 implementation of quiz data search from and LmsAPITemplate --- .../ch/ethz/seb/sebserver/gbl/Constants.java | 5 + .../ch/ethz/seb/sebserver/gbl/api/API.java | 4 +- .../seb/sebserver/gbl/api/APIMessage.java | 17 +- .../seb/sebserver/gbl/model/exam/Exam.java | 2 - .../sebserver/gbl/model/exam/QuizData.java | 81 +++++- .../gbl/model/institution/LmsSetup.java | 7 +- .../model/institution/LmsSetupTestResult.java | 29 +- .../ethz/seb/sebserver/gbl/util/Result.java | 14 + .../gbl/util/SupplierWithCircuitBreaker.java | 42 +++ .../ch/ethz/seb/sebserver/gbl/util/Utils.java | 6 + .../gui/content/InstitutionForm.java | 2 +- .../sebserver/gui/content/LmsSetupForm.java | 68 ++++- .../UserAccountChangePasswordForm.java | 2 +- .../gui/content/UserAccountForm.java | 2 +- .../gui/content/action/ActionDefinition.java | 17 +- .../ch/ethz/seb/sebserver/gui/form/Form.java | 9 + .../seb/sebserver/gui/form/FormHandle.java | 64 +++-- .../gui/service/page/PageContext.java | 8 + .../gui/service/page/action/Action.java | 9 +- .../service/page/impl/PageContextImpl.java | 2 +- .../webservice/api/lmssetup/TestLmsSetup.java | 40 +++ .../sebserver/gui/widget/WidgetFactory.java | 1 + .../datalayer/batis/BatisConfig.java | 15 ++ .../authorization/AuthorizationService.java | 10 + .../authorization/UserService.java | 4 + .../servicelayer/bulkaction/BulkAction.java | 14 +- .../bulkaction/BulkActionService.java | 171 +----------- .../bulkaction/BulkActionServiceImpl.java | 186 +++++++++++++ .../bulkaction/BulkActionSupportDAO.java | 17 ++ .../client/ClientCredentialService.java | 176 +------------ .../client/ClientCredentialServiceImpl.java | 195 ++++++++++++++ .../servicelayer/dao/DAOLoggingSupport.java | 1 + .../servicelayer/dao/FilterMap.java | 16 +- .../servicelayer/dao/impl/ExamDAOImpl.java | 4 +- .../dao/impl/LmsSetupDAOImpl.java | 2 +- .../servicelayer/lms/LmsAPIService.java | 79 +++++- .../servicelayer/lms/LmsAPITemplate.java | 62 ++++- .../lms/impl/LmsAPIServiceImpl.java | 235 +++++++++++++---- .../lms/impl/LmsSetupChangeEvent.java | 27 ++ .../lms/impl/MockupLmsAPITemplate.java | 130 +++------ .../lms/impl/OpenEdxLmsAPITemplate.java | 248 +++++++++--------- .../weblayer/api/APIExceptionHandler.java | 2 +- .../api/ExamAdministrationController.java | 2 +- .../weblayer/api/LmsSetupController.java | 9 +- .../weblayer/api/QuizImportController.java | 27 +- src/main/resources/messages.properties | 7 + src/main/resources/schema-dev.sql | 3 + src/main/resources/static/images/test.png | Bin 0 -> 146 bytes .../client/ClientCredentialServiceTest.java | 12 +- 49 files changed, 1371 insertions(+), 714 deletions(-) create mode 100644 src/main/java/ch/ethz/seb/sebserver/gbl/util/SupplierWithCircuitBreaker.java create mode 100644 src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/lmssetup/TestLmsSetup.java create mode 100644 src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkActionServiceImpl.java create mode 100644 src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/client/ClientCredentialServiceImpl.java create mode 100644 src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/LmsSetupChangeEvent.java create mode 100644 src/main/resources/static/images/test.png diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/Constants.java b/src/main/java/ch/ethz/seb/sebserver/gbl/Constants.java index 6b873f8e..dac5c840 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/Constants.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/Constants.java @@ -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 = "--"; diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java b/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java index 21918db6..2cb089e8 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java @@ -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"; diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/api/APIMessage.java b/src/main/java/ch/ethz/seb/sebserver/gbl/api/APIMessage.java index 5db80b43..1ea5fa70 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/api/APIMessage.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/api/APIMessage.java @@ -169,25 +169,30 @@ public class APIMessage implements Serializable { private static final long serialVersionUID = 1453431210820677296L; - private final APIMessage apiMessage; + private final Collection apiMessages; + + public APIMessageException(final Collection 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 getAPIMessages() { + return this.apiMessages; } } diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/Exam.java b/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/Exam.java index b8d450f9..35cc55dd 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/Exam.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/Exam.java @@ -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"; diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/QuizData.java b/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/QuizData.java index b7967c9d..cecd7f64 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/QuizData.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/QuizData.java @@ -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 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 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 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 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 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); + } + } diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetup.java b/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetup.java index 48157e92..585dd5f7 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetup.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetup.java @@ -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) diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetupTestResult.java b/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetupTestResult.java index 099353ce..d327423c 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetupTestResult.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetupTestResult.java @@ -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 missingLMSSetupAttribute; + public final List 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 missingLMSSetupAttribute, - @JsonProperty(ATTR_MISSING_ATTRIBUTE) final String tokenRequestError, - @JsonProperty(ATTR_MISSING_ATTRIBUTE) final String quizRequestError) { + @JsonProperty(ATTR_MISSING_ATTRIBUTE) final Collection 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 getMissingLMSSetupAttribute() { + public List 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 attrs) { + public static final LmsSetupTestResult ofMissingAttributes(final Collection 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); } diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/util/Result.java b/src/main/java/ch/ethz/seb/sebserver/gbl/util/Result.java index 06e2625a..fb9cf2d5 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/util/Result.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/util/Result.java @@ -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 The type of the result value */ public final class Result { + 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 { } } + public static Stream onErrorLogAndSkip(final Result 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; diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/util/SupplierWithCircuitBreaker.java b/src/main/java/ch/ethz/seb/sebserver/gbl/util/SupplierWithCircuitBreaker.java new file mode 100644 index 00000000..651af847 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/util/SupplierWithCircuitBreaker.java @@ -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 implements Supplier> { + + private final Supplier supplierThatCanFailOrBlock; + private final int maxFailingAttempts; + private final long maxBlockingTime; + + private final T cached = null; + + public SupplierWithCircuitBreaker( + final Supplier supplierThatCanFailOrBlock, + final int maxFailingAttempts, + final long maxBlockingTime) { + + this.supplierThatCanFailOrBlock = supplierThatCanFailOrBlock; + this.maxFailingAttempts = maxFailingAttempts; + this.maxBlockingTime = maxBlockingTime; + } + + @Override + public Result 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()); + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/util/Utils.java b/src/main/java/ch/ethz/seb/sebserver/gbl/util/Utils.java index 1e0d97cc..26170478 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/util/Utils.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/util/Utils.java @@ -155,4 +155,10 @@ public final class Utils { } } + public static final String formatHTMLLines(final String message) { + return (message != null) + ? message.replace("\n", "
") + : null; + } + } diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/InstitutionForm.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/InstitutionForm.java index fbff4c4d..fa5768c7 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/InstitutionForm.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/InstitutionForm.java @@ -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) diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/LmsSetupForm.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/LmsSetupForm.java index 85aa784b..875f5a49 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/LmsSetupForm.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/LmsSetupForm.java @@ -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 formHandle) { + // If we are in edit-mode we have to save the form before testing + if (!action.pageContext().isReadonly()) { + final Result 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 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)); + } + } + } diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/UserAccountChangePasswordForm.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/UserAccountChangePasswordForm.java index 35bf1452..3d8ee63f 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/UserAccountChangePasswordForm.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/UserAccountChangePasswordForm.java @@ -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 diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/UserAccountForm.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/UserAccountForm.java index ebe86cd6..939c0acd 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/UserAccountForm.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/UserAccountForm.java @@ -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(); diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/action/ActionDefinition.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/action/ActionDefinition.java index 4bb34ff6..37d2eaff 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/action/ActionDefinition.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/action/ActionDefinition.java @@ -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> restCallType; public final ActionDefinition activityAlias; public final String category; - public final boolean readonly; + public final Boolean readonly; private ActionDefinition( final LocTextKey title, final Class 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 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 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> 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> restCallType, final ActionDefinition activityAlias, final String category, - final boolean readonly) { + final Boolean readonly) { this.title = title; this.icon = icon; diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/form/Form.java b/src/main/java/ch/ethz/seb/sebserver/gui/form/Form.java index 4c4fbbb0..ff3307b7 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/form/Form.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/form/Form.java @@ -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 nameFilter, final Consumer processor) { diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/form/FormHandle.java b/src/main/java/ch/ethz/seb/sebserver/gui/form/FormHandle.java index 86f9ebd5..7786183b 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/form/FormHandle.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/form/FormHandle.java @@ -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 { 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 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 doAPIPost() { this.form.process( name -> true, fieldAccessor -> fieldAccessor.resetError()); @@ -63,34 +75,52 @@ public class FormHandle { 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 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) { diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/page/PageContext.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/page/PageContext.java index 8b53b095..0a244847 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/page/PageContext.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/page/PageContext.java @@ -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 */ diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/page/action/Action.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/page/action/Action.java index 647a6d32..0ebdd0d9 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/page/action/Action.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/page/action/Action.java @@ -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 exec) { this.exec = exec; return this; diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/page/impl/PageContextImpl.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/page/impl/PageContextImpl.java index 2672a527..2ca841f8 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/page/impl/PageContextImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/page/impl/PageContextImpl.java @@ -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); } diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/lmssetup/TestLmsSetup.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/lmssetup/TestLmsSetup.java new file mode 100644 index 00000000..4a312f22 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/lmssetup/TestLmsSetup.java @@ -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 { + + protected TestLmsSetup() { + super(new TypeKey<>( + CallType.UNDEFINED, + EntityType.LMS_SETUP, + new TypeReference() { + }), + HttpMethod.GET, + MediaType.APPLICATION_FORM_URLENCODED, + API.LMS_SETUP_TEST_ENDPOINT); + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/widget/WidgetFactory.java b/src/main/java/ch/ethz/seb/sebserver/gui/widget/WidgetFactory.java index 09137566..4f44a2ca 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/widget/WidgetFactory.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/widget/WidgetFactory.java @@ -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"), diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/datalayer/batis/BatisConfig.java b/src/main/java/ch/ethz/seb/sebserver/webservice/datalayer/batis/BatisConfig.java index 1140f808..98bf09da 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/datalayer/batis/BatisConfig.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/datalayer/batis/BatisConfig.java @@ -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) { diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/AuthorizationService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/AuthorizationService.java index 18eabe6f..7cba82c7 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/AuthorizationService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/AuthorizationService.java @@ -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 userRolesOfUserAccount = userAccount.getUserRoles(); final SEBServerUser currentUser = getUserService().getCurrentUser(); diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/UserService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/UserService.java index 6dcc396d..31138fe6 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/UserService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/UserService.java @@ -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); } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkAction.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkAction.java index fa5fd94f..3639908d 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkAction.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkAction.java @@ -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 sources; - + /** A Set of EntityKey containing collected depending entities during dependency collection and processing phase */ final Set dependencies; + /** A Set of EntityKey containing collected bulk action processing results during processing phase */ final Set> result; - + /** Indicates if this BulkAction has already been processed and is not valid anymore */ boolean alreadyProcessed = false; public BulkAction( diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkActionService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkActionService.java index b1a3346b..6cbbc601 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkActionService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkActionService.java @@ -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> supporter; - private final UserActivityLogDAO userActivityLogDAO; + void collectDependencies(BulkAction action); - public BulkActionService( - final Collection> supporter, - final UserActivityLogDAO userActivityLogDAO) { + Result doBulkAction(BulkAction action); - this.supporter = new HashMap<>(); - for (final BulkActionSupportDAO support : supporter) { - this.supporter.put(support.entityType(), support); - } - this.userActivityLogDAO = userActivityLogDAO; - } + Result 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 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> 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 createReport(final BulkAction action) { - if (!action.alreadyProcessed) { - return doBulkAction(action) - .flatMap(this::createFullReport); - } else { - return createFullReport(action); - } - } - - private Result 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> getDependancySupporter(final BulkAction action) { - switch (action.type) { - case ACTIVATE: - case DEACTIVATE: - case HARD_DELETE: { - final List> dependantSupporterInHierarchicalOrder = - getDependantSupporterInHierarchicalOrder(action); - Collections.reverse(dependantSupporterInHierarchicalOrder); - return dependantSupporterInHierarchicalOrder - .stream() - .filter(v -> v != null) - .collect(Collectors.toList()); - } - default: - return getDependantSupporterInHierarchicalOrder(action); - } - } - - private List> 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"); - } - } - -} +} \ No newline at end of file diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkActionServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkActionServiceImpl.java new file mode 100644 index 00000000..633f846d --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkActionServiceImpl.java @@ -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> supporter; + private final UserActivityLogDAO userActivityLogDAO; + + public BulkActionServiceImpl( + final Collection> 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 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> 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 createReport(final BulkAction action) { + if (!action.alreadyProcessed) { + return doBulkAction(action) + .flatMap(this::createFullReport); + } else { + return createFullReport(action); + } + } + + private Result 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> getDependancySupporter(final BulkAction action) { + switch (action.type) { + case ACTIVATE: + case DEACTIVATE: + case HARD_DELETE: { + final List> dependantSupporterInHierarchicalOrder = + getDependantSupporterInHierarchicalOrder(action); + Collections.reverse(dependantSupporterInHierarchicalOrder); + return dependantSupporterInHierarchicalOrder + .stream() + .filter(v -> v != null) + .collect(Collectors.toList()); + } + default: + return getDependantSupporterInHierarchicalOrder(action); + } + } + + private List> 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"); + } + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkActionSupportDAO.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkActionSupportDAO.java index 20029f62..91e71139 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkActionSupportDAO.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/bulkaction/BulkActionSupportDAO.java @@ -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 The type of the Entity of a concrete BulkActionSupportDAO */ public interface BulkActionSupportDAO { /** Get the entity type for a concrete EntityDAO implementation. @@ -32,8 +36,21 @@ public interface BulkActionSupportDAO { * @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 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> processBulkAction(final BulkAction bulkAction) { final Set all = bulkAction.extractKeys(entityType()); diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/client/ClientCredentialService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/client/ClientCredentialService.java index 7734c38e..f737f6a4 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/client/ClientCredentialService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/client/ClientCredentialService.java @@ -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 createGeneratedClientCredentials(); - static final String SEBSERVER_WEBSERVICE_INTERNAL_SECRET_KEY = "sebserver.webservice.internalSecret"; - static final CharSequence DEFAULT_SALT = "b7dbe99bbfa3e21e"; + Result 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 createGeneratedClientCredentials() { - return createGeneratedClientCredentials(null); - } + CharSequence getPlainClientId(ClientCredentials credentials); - public Result 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()); - } - -} +} \ No newline at end of file diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/client/ClientCredentialServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/client/ClientCredentialServiceImpl.java new file mode 100644 index 00000000..dbe9dec9 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/client/ClientCredentialServiceImpl.java @@ -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 createGeneratedClientCredentials() { + return createGeneratedClientCredentials(null); + } + + @Override + public Result 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()); + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/DAOLoggingSupport.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/DAOLoggingSupport.java index 354a9715..15b80884 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/DAOLoggingSupport.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/DAOLoggingSupport.java @@ -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); diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/FilterMap.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/FilterMap.java index 0c2d9cc5..035eb1ab 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/FilterMap.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/FilterMap.java @@ -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() { diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamDAOImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamDAOImpl.java index f41bc74f..759914d4 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamDAOImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamDAOImpl.java @@ -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() diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/LmsSetupDAOImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/LmsSetupDAOImpl.java index 60a94458..78fdbdd6 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/LmsSetupDAOImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/LmsSetupDAOImpl.java @@ -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())) diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/LmsAPIService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/LmsAPIService.java index e00a3339..04f21cd2 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/LmsAPIService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/LmsAPIService.java @@ -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 createLmsAPITemplate(Long lmsSetupId); + Result> requestQuizDataPage( + final int pageNumber, + final int pageSize, + final String sort, + final FilterMap filterMap); - Result createLmsAPITemplate(LmsSetup lmsSetup); + /** Get a LmsAPITemplate for specified LmsSetup configuration. + * + * @param lmsSetupId the identifier of LmsSetup + * @return LmsAPITemplate for specified LmsSetup configuration */ + Result getLmsAPITemplate(String lmsSetupId); + + default Result getLmsAPITemplate(final Long lmsSetupId) { + if (lmsSetupId == null) { + return Result.ofError(new IllegalArgumentException("lmsSetupId has null-reference")); + } + return getLmsAPITemplate(String.valueOf(lmsSetupId)); + } + + public static Predicate 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> quizzesFilterFunction(final FilterMap filterMap) { + filterMap.getName(); + return quizzes -> quizzes + .stream() + .filter(quizzeFilterFunction(filterMap)) + .collect(Collectors.toList()); + } + + public static Function, Page> 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> quizzesSortFunction(final String sort) { + return quizzes -> { + quizzes.sort(QuizData.getComparator(sort)); + return quizzes; + }; + } } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/LmsAPITemplate.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/LmsAPITemplate.java index 12761e99..2e2119b3 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/LmsAPITemplate.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/LmsAPITemplate.java @@ -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(); + /** 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> 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> getQuizzes(FilterMap filterMap); Collection> getQuizzes(Set ids); Result getExamineeAccountDetails(String examineeUserId); - void reset(); + default List attributeValidation(final ClientCredentials credentials) { + + final LmsSetup lmsSetup = lmsSetup(); + // validation of LmsSetup + final List 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; + } } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/LmsAPIServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/LmsAPIServiceImpl.java index 65a991b4..63d89e0e 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/LmsAPIServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/LmsAPIServiceImpl.java @@ -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 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 createLmsAPITemplate(final Long lmsSetupId) { - return this.lmsSetupDAO - .byPK(lmsSetupId) - .flatMap(this::createLmsAPITemplate); - } - - @Override - public Result 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 createSEBStartConfiguration(final Long lmsSetupId) { -// return this.lmsSetupDAO -// .byPK(lmsSetupId) -// .flatMap(this::createSEBStartConfiguration); -// } -// -// @Override -// public Result 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> 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> 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 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 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; + } + } } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/LmsSetupChangeEvent.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/LmsSetupChangeEvent.java new file mode 100644 index 00000000..e3b26315 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/LmsSetupChangeEvent.java @@ -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; + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/MockupLmsAPITemplate.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/MockupLmsAPITemplate.java index e187e566..7a14268d 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/MockupLmsAPITemplate.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/MockupLmsAPITemplate.java @@ -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 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() { - 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 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 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 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> getQuizzes( - final String name, - final Long from, - final String sort, - final int pageNumber, - final int pageSize) { + public Result> 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 quizzes = getQuizzes(name, from, sort); - final int numberOfPages = quizzes.size() / pageSize; - final Iterator iterator = quizzes.iterator(); - final List 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> getQuizzes(final Set 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 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; } } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/OpenEdxLmsAPITemplate.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/OpenEdxLmsAPITemplate.java index 06adbf54..7e51f556 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/OpenEdxLmsAPITemplate.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/OpenEdxLmsAPITemplate.java @@ -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 knownTokenAccessPaths; private OAuth2RestTemplate restTemplate = null; + private SupplierWithCircuitBreaker> 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() { - 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 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 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> getQuizzes( - final String name, - final Long from, - final String sort, - final int pageNumber, - final int pageSize) { + public Result> 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 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 content = edxpage.results - .stream() - .reduce( - new ArrayList(), - (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 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 initRestTemplateAndRequestAccessToken() { - private Result 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 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> getAllQuizes(final LmsSetup lmsSetup) { + if (this.allQuizzesSupplier == null) { + this.allQuizzesSupplier = new SupplierWithCircuitBreaker<>( + () -> collectAllCourses(lmsSetup.lmsApiUrl + OPEN_EDX_DEFAULT_COURSE_ENDPOINT) + .stream() + .reduce( + new ArrayList(), + (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 collectAllCourses(final String pageURI) { + final List 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 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 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); + } + } } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/APIExceptionHandler.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/APIExceptionHandler.java index 3bf253f3..ce5d69df 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/APIExceptionHandler.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/APIExceptionHandler.java @@ -137,7 +137,7 @@ public class APIExceptionHandler extends ResponseEntityExceptionHandler { final WebRequest request) { return new ResponseEntity<>( - Arrays.asList(ex.getAPIMessage()), + ex.getAPIMessages(), HttpStatus.BAD_REQUEST); } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamAdministrationController.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamAdministrationController.java index 303d09d7..45e6f833 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamAdministrationController.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamAdministrationController.java @@ -253,7 +253,7 @@ public class ExamAdministrationController extends ActivatableEntityController(Arrays.asList(quizId))) diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/LmsSetupController.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/LmsSetupController.java index 8a336deb..cda9ed11 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/LmsSetupController.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/LmsSetupController.java @@ -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 template.testLmsSetup()) .getOrThrow(); + + if (result.missingLMSSetupAttribute != null && !result.missingLMSSetupAttribute.isEmpty()) { + throw new APIMessageException(result.missingLMSSetupAttribute); + } + + return result; } @Override diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/QuizImportController.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/QuizImportController.java index 2dffbdf7..8d3134a5 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/QuizImportController.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/QuizImportController.java @@ -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 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(); } diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 68a23be2..e81b6294 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -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.
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 diff --git a/src/main/resources/schema-dev.sql b/src/main/resources/schema-dev.sql index 8abbc24e..b1186e79 100644 --- a/src/main/resources/schema-dev.sql +++ b/src/main/resources/schema-dev.sql @@ -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` -- ----------------------------------------------------- diff --git a/src/main/resources/static/images/test.png b/src/main/resources/static/images/test.png new file mode 100644 index 0000000000000000000000000000000000000000..254f222f635745b23e66bfee33940b396aff925c GIT binary patch literal 146 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+0wn(&ce?|mB0XIkLn;`PC6*j$T*%04m9T=5 z>z{iwFLSD_GPAv+^$f{r#&;StiXAqeXlWE()6$wKd*}+ANcR-UeFkrm5?BxE?)ZE1 ugn_um&gMkzg$ri5#bkUvJoSP+1H*@AlZ`SG5z0VI7(8A5T-G@yGywqSqA%nC literal 0 HcmV?d00001 diff --git a/src/test/java/ch/ethz/seb/sebserver/webservice/servicelayer/client/ClientCredentialServiceTest.java b/src/test/java/ch/ethz/seb/sebserver/webservice/servicelayer/client/ClientCredentialServiceTest.java index df63ad32..95fd5a84 100644 --- a/src/test/java/ch/ethz/seb/sebserver/webservice/servicelayer/client/ClientCredentialServiceTest.java +++ b/src/test/java/ch/ethz/seb/sebserver/webservice/servicelayer/client/ClientCredentialServiceTest.java @@ -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); }