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 7940521c..5310e5bf 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/Constants.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/Constants.java @@ -38,6 +38,8 @@ public final class Constants { public static final Character CARRIAGE_RETURN = '\n'; public static final Character CURLY_BRACE_OPEN = '{'; public static final Character CURLY_BRACE_CLOSE = '}'; + public static final Character SQUARE_BRACE_OPEN = '['; + public static final Character SQUARE_BRACE_CLOSE = ']'; public static final Character COLON = ':'; public static final Character SEMICOLON = ';'; public static final Character PERCENTAGE = '%'; diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/async/CircuitBreaker.java b/src/main/java/ch/ethz/seb/sebserver/gbl/async/CircuitBreaker.java index 9fcce361..1ca2ba40 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/async/CircuitBreaker.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/async/CircuitBreaker.java @@ -75,7 +75,6 @@ public final class CircuitBreaker { * * @param asyncRunner the AsyncRunner used to create asynchronous calls on the given supplier function */ CircuitBreaker(final AsyncRunner asyncRunner) { - this( asyncRunner, DEFAULT_MAX_FAILING_ATTEMPTS, @@ -174,7 +173,9 @@ public final class CircuitBreaker { this.state = State.HALF_OPEN; this.failingCount.set(0); - return Result.ofError(OPEN_STATE_EXCEPTION); + return Result.ofError(new RuntimeException( + "Set CircuitBraker to half-open state. Cause: ", + result.getError())); } else { // try again return protectedRun(supplier); @@ -202,7 +203,9 @@ public final class CircuitBreaker { } this.state = State.OPEN; - return Result.ofError(OPEN_STATE_EXCEPTION); + return Result.ofError(new RuntimeException( + "Set CircuitBraker to open state. Cause: ", + result.getError())); } else { // on success go to CLODED state if (log.isDebugEnabled()) { 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 76c4b913..ff06c538 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 @@ -45,7 +45,8 @@ public final class LmsSetup implements GrantEntity, Activatable { public enum LmsType { MOCKUP(Features.COURSE_API), - OPEN_EDX(Features.COURSE_API, Features.SEB_RESTICTION); + OPEN_EDX(Features.COURSE_API, Features.SEB_RESTICTION), + MOODLE(Features.COURSE_API); public final EnumSet features; 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 5ee09d82..e9c0ad1d 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 @@ -269,10 +269,30 @@ public final class Utils { return dateTime.withZone(DateTimeZone.UTC); } + public static DateTime toDateTimeUTC(final Long timestamp) { + if (timestamp == null) { + return null; + } else { + return toDateTimeUTC(timestamp.longValue()); + } + } + public static DateTime toDateTimeUTC(final long timestamp) { return new DateTime(timestamp, DateTimeZone.UTC); } + public static DateTime toDateTimeUTCUnix(final Long timestamp) { + if (timestamp == null || timestamp.longValue() <= 0) { + return null; + } else { + return toDateTimeUTCUnix(timestamp.longValue()); + } + } + + public static DateTime toDateTimeUTCUnix(final long timestamp) { + return new DateTime(timestamp * 1000, DateTimeZone.UTC); + } + public static Long toTimestamp(final String dateString) { if (StringUtils.isBlank(dateString)) { return null; @@ -547,4 +567,48 @@ public final class Utils { return StringUtils.EMPTY; } } + + public static String toAppFormUrlEncodedBody(final MultiValueMap attributes) { + return attributes + .entrySet() + .stream() + .reduce( + new StringBuilder(), + (sb, entry) -> { + final String name = entry.getKey(); + final List values = entry.getValue(); + if (values == null || values.isEmpty()) { + return sb; + } + if (sb.length() > 0) { + sb.append(Constants.AMPERSAND); + } + if (sb.length() == 1) { + return sb.append(name).append(Constants.EQUALITY_SIGN).append(values.get(0)); + } + return sb.append(toAppFormUrlEncodedBody(name, values)); + }, + (sb1, sb2) -> sb1.append(sb2)) + .toString(); + } + + public static final String toAppFormUrlEncodedBody(final String name, final Collection array) { + final String _name = name.contains(String.valueOf(Constants.SQUARE_BRACE_OPEN)) + ? name + : name + Constants.SQUARE_BRACE_OPEN + Constants.SQUARE_BRACE_CLOSE; + + return array + .stream() + .reduce( + new StringBuilder(), + (sb, entry) -> { + if (sb.length() > 0) { + sb.append(Constants.AMPERSAND); + } + return sb.append(_name).append(Constants.EQUALITY_SIGN).append(entry); + }, + (sb1, sb2) -> sb1.append(sb2)) + .toString(); + } + } diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/ExamForm.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/ExamForm.java index c787cde6..c0d9af38 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/ExamForm.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/ExamForm.java @@ -312,7 +312,7 @@ public class ExamForm implements TemplateComposer { QuizData.QUIZ_ATTR_DESCRIPTION, FORM_DESCRIPTION_TEXT_KEY, exam.description) - .asArea() + .asHTML() .readonly(true) .withInputSpan(6) .withEmptyCellSeparation(false)) diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/QuizDiscoveryList.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/QuizDiscoveryList.java index 96b36c4a..8acdcab2 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/QuizDiscoveryList.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/QuizDiscoveryList.java @@ -257,9 +257,10 @@ public class QuizDiscoveryList implements TemplateComposer { action.getSingleSelection(); - final ModalInputDialog dialog = new ModalInputDialog<>( + final ModalInputDialog dialog = new ModalInputDialog( action.pageContext().getParent().getShell(), - this.widgetFactory); + this.widgetFactory) + .setLargeDialogWidth(); dialog.open( DETAILS_TITLE_TEXT_KEY, @@ -277,7 +278,7 @@ public class QuizDiscoveryList implements TemplateComposer { final Composite parent = pc.getParent(); final Composite grid = this.widgetFactory.createPopupScrollComposite(parent); - this.pageService.formBuilder(pc.copyOf(grid), 3) + final FormBuilder formbuilder = this.pageService.formBuilder(pc.copyOf(grid), 3) .withEmptyCellSeparation(false) .readonly(true) .addFieldIf( @@ -299,7 +300,7 @@ public class QuizDiscoveryList implements TemplateComposer { QuizData.QUIZ_ATTR_DESCRIPTION, QUIZ_DETAILS_DESCRIPTION_TEXT_KEY, quizData.description) - .asArea()) + .asHTML()) .addField(FormBuilder.text( QuizData.QUIZ_ATTR_START_TIME, QUIZ_DETAILS_STARTTIME_TEXT_KEY, @@ -311,8 +312,20 @@ public class QuizDiscoveryList implements TemplateComposer { .addField(FormBuilder.text( QuizData.QUIZ_ATTR_START_URL, QUIZ_DETAILS_URL_TEXT_KEY, - quizData.startURL)) - .build(); + quizData.startURL)); + + if (!quizData.additionalAttributes.isEmpty()) { + quizData.additionalAttributes + .entrySet() + .stream() + .forEach(entry -> formbuilder + .addField(FormBuilder.text( + entry.getKey(), + new LocTextKey(entry.getKey()), + entry.getValue()))); + } + + formbuilder.build(); } } 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 6f6e9b24..6fa3421a 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 @@ -21,6 +21,7 @@ import java.util.function.Predicate; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; import org.eclipse.rap.rwt.RWT; +import org.eclipse.swt.browser.Browser; import org.eclipse.swt.graphics.Color; import org.eclipse.swt.widgets.Button; import org.eclipse.swt.widgets.Control; @@ -127,6 +128,11 @@ public final class Form implements FormBinding { return this; } + Form putReadonlyField(final String name, final Label label, final Browser field) { + this.formFields.add(name, createReadonlyAccessor(label, field)); + return this; + } + Form putField(final String name, final Label label, final Text field, final Label errorLabel) { this.formFields.add(name, createAccessor(label, field, errorLabel)); return this; @@ -285,6 +291,12 @@ public final class Form implements FormBinding { @Override public void setStringValue(final String value) { field.setText( (value == null) ? StringUtils.EMPTY : value); } }; } + private FormFieldAccessor createReadonlyAccessor(final Label label, final Browser field) { + return new FormFieldAccessor(label, field, null) { + @Override public String getStringValue() { return null; } + @Override public void setStringValue(final String value) { field.setText( (value == null) ? StringUtils.EMPTY : value); } + }; + } private FormFieldAccessor createAccessor(final Label label, final Text text, final Label errorLabel) { return new FormFieldAccessor(label, text, errorLabel) { @Override public String getStringValue() {return text.getText();} diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/form/TextFieldBuilder.java b/src/main/java/ch/ethz/seb/sebserver/gui/form/TextFieldBuilder.java index 187bff6a..4b1e3f64 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/form/TextFieldBuilder.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/form/TextFieldBuilder.java @@ -13,6 +13,7 @@ import java.util.function.Consumer; import org.apache.commons.lang3.StringUtils; import org.eclipse.rap.rwt.RWT; import org.eclipse.swt.SWT; +import org.eclipse.swt.browser.Browser; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Label; @@ -24,12 +25,17 @@ import ch.ethz.seb.sebserver.gui.widget.WidgetFactory; public final class TextFieldBuilder extends FieldBuilder { + private static final String HTML_TEXT_BLOCK_START = + ""; + private static final String HTML_TEXT_BLOCK_END = ""; + boolean isPassword = false; boolean isNumber = false; Consumer numberCheck = null; boolean isArea = false; int areaMinHeight = WidgetFactory.TEXT_AREA_INPUT_MIN_HEIGHT; boolean isColorbox = false; + boolean isHTML = false; TextFieldBuilder(final String name, final LocTextKey label, final String value) { super(name, label, value); @@ -62,6 +68,11 @@ public final class TextFieldBuilder extends FieldBuilder { return this; } + public TextFieldBuilder asHTML() { + this.isHTML = true; + return this; + } + public TextFieldBuilder asColorbox() { this.isColorbox = true; return this; @@ -72,6 +83,21 @@ public final class TextFieldBuilder extends FieldBuilder { final boolean readonly = builder.readonly || this.readonly; final Label titleLabel = createTitleLabel(builder.formParent, builder, this); final Composite fieldGrid = createFieldGrid(builder.formParent, this.spanInput); + + if (readonly && this.isHTML) { + final Browser browser = new Browser(fieldGrid, SWT.NONE); + final GridData gridData = new GridData(SWT.FILL, SWT.TOP, true, true); + gridData.minimumHeight = this.areaMinHeight; + browser.setLayoutData(gridData); + if (StringUtils.isNoneBlank(this.value)) { + browser.setText(HTML_TEXT_BLOCK_START + this.value + HTML_TEXT_BLOCK_END); + } else if (readonly) { + browser.setText(Constants.EMPTY_NOTE); + } + builder.form.putReadonlyField(this.name, titleLabel, browser); + return; + } + final Text textInput = (this.isNumber) ? builder.widgetFactory.numberInput(fieldGrid, this.numberCheck, readonly) : (this.isArea) diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/page/PageService.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/page/PageService.java index 5b9d9317..48965aaf 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/page/PageService.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/page/PageService.java @@ -194,7 +194,7 @@ public interface PageService { * @param the type of the Entity of the table * @return TableBuilder of specified type */ default TableBuilder entityTableBuilder(final RestCall> apiCall) { - return entityTableBuilder(apiCall.getEntityType().name(), apiCall); + return entityTableBuilder(apiCall.getClass().getSimpleName(), apiCall); } /** Get an new TableBuilder for specified page based RestCall. diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/table/EntityTable.java b/src/main/java/ch/ethz/seb/sebserver/gui/table/EntityTable.java index fb44e1f7..36ac3e9a 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/table/EntityTable.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/table/EntityTable.java @@ -213,7 +213,7 @@ public class EntityTable { this.navigator = new TableNavigator(this); createTableColumns(); - initCurrentPageFromUserAttr(); + this.pageNumber = initCurrentPageFromUserAttr(); initFilterFromUserAttrs(); initSortFromUserAttr(); updateTableRows( @@ -269,7 +269,7 @@ public class EntityTable { this.sortColumn, this.sortOrder); - updateCurrentPageAttr(pageSelection); + updateCurrentPageAttr(); } public void reset() { @@ -282,14 +282,8 @@ public class EntityTable { public void applyFilter() { try { - updateTableRows( - this.pageNumber, - this.pageSize, - this.sortColumn, - this.sortOrder); - updateFilterUserAttrs(); - this.selectPage(0); + this.selectPage(1); } catch (final Exception e) { log.error("Unexpected error while trying to apply filter: ", e); @@ -301,11 +295,13 @@ public class EntityTable { this.sortColumn = columnName; this.sortOrder = PageSortOrder.ASCENDING; - updateTableRows( - this.pageNumber, - this.pageSize, - this.sortColumn, - this.sortOrder); + if (columnName != null) { + updateTableRows( + this.pageNumber, + this.pageSize, + this.sortColumn, + this.sortOrder); + } updateSortUserAttr(); @@ -632,26 +628,29 @@ public class EntityTable { // TODO handle selection tool-tips on cell level } - private void updateCurrentPageAttr(final int page) { + private void updateCurrentPageAttr() { try { this.pageService .getCurrentUser() - .putAttribute(this.currentPageAttrName, String.valueOf(page)); + .putAttribute(this.currentPageAttrName, String.valueOf(this.pageNumber)); } catch (final Exception e) { log.error("Failed to put current page attribute to current user attributes", e); } } - private void initCurrentPageFromUserAttr() { + private int initCurrentPageFromUserAttr() { try { final String currentPage = this.pageService .getCurrentUser() .getAttribute(this.currentPageAttrName); if (StringUtils.isNotBlank(currentPage)) { - this.selectPage(Integer.parseInt(currentPage)); + return Integer.parseInt(currentPage); + } else { + return 1; } } catch (final Exception e) { log.error("Failed to get sort attribute form current user attributes", e); + return 1; } } diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/widget/Message.java b/src/main/java/ch/ethz/seb/sebserver/gui/widget/Message.java index 3dca7f00..fa154273 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/widget/Message.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/widget/Message.java @@ -14,6 +14,7 @@ import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.MessageBox; import org.eclipse.swt.widgets.Shell; +import ch.ethz.seb.sebserver.gbl.util.Utils; import ch.ethz.seb.sebserver.gui.widget.WidgetFactory.CustomVariant; public final class Message extends MessageBox { @@ -35,7 +36,13 @@ public final class Message extends MessageBox { @Override protected void prepareOpen() { - super.prepareOpen(); + try { + super.prepareOpen(); + } catch (final IllegalArgumentException e) { + // fallback on markup text error + super.setMessage(Utils.escapeHTML_XML_EcmaScript(super.getMessage())); + super.prepareOpen(); + } final GridLayout layout = (GridLayout) super.shell.getLayout(); layout.marginTop = 10; layout.marginLeft = 10; 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 abb99c30..1034d4e5 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 @@ -149,7 +149,7 @@ public interface LmsAPIService { } return new Page<>( - (quizzes.size() / pageSize) + 1, + (quizzes.size() / pageSize), pageNumber, sortAttribute, quizzes.subList(start, end)); 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 9b593d2d..b59e6f54 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 @@ -10,9 +10,11 @@ package ch.ethz.seb.sebserver.webservice.servicelayer.lms; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; @@ -68,7 +70,14 @@ public interface LmsAPITemplate { * * @param ids the Set of Quiz identifiers to get the QuizData for * @return Collection of all QuizData from the given id set */ - Collection> getQuizzes(Set ids); + default Collection> getQuizzes(final Set ids) { + return getQuizzes(new FilterMap()) + .getOrElse(() -> Collections.emptyList()) + .stream() + .filter(quiz -> ids.contains(quiz.id)) + .map(quiz -> Result.of(quiz)) + .collect(Collectors.toList()); + } /** Get all QuizData for the set of QuizData identifiers from LMS API in a collection * of Result. If particular Quiz cannot be loaded because of errors or deletion, diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/CourseAccess.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/CourseAccess.java new file mode 100644 index 00000000..bb117371 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/CourseAccess.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2020 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import ch.ethz.seb.sebserver.gbl.Constants; +import ch.ethz.seb.sebserver.gbl.async.AsyncService; +import ch.ethz.seb.sebserver.gbl.async.MemoizingCircuitBreaker; +import ch.ethz.seb.sebserver.gbl.model.exam.QuizData; +import ch.ethz.seb.sebserver.gbl.util.Result; +import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap; +import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPIService; + +public abstract class CourseAccess { + + protected final MemoizingCircuitBreaker> allQuizzesSupplier; + + protected CourseAccess(final AsyncService asyncService) { + this.allQuizzesSupplier = asyncService.createMemoizingCircuitBreaker( + allQuizzesSupplier(), + 3, + Constants.MINUTE_IN_MILLIS, + Constants.MINUTE_IN_MILLIS, + true, + Constants.HOUR_IN_MILLIS); + } + + public Result getQuizFromCache(final String id) { + return Result.tryCatch(() -> { + return this.allQuizzesSupplier + .getChached() + .stream() + .filter(qd -> id.equals(qd.id)) + .findFirst() + .orElseThrow(() -> new NoSuchElementException("No cached quiz: " + id)); + }); + } + + public Result>> getQuizzesFromCache(final Set ids) { + return Result.tryCatch(() -> { + final List cached = this.allQuizzesSupplier.getChached(); + if (cached == null) { + throw new RuntimeException("No cached quizzes"); + } + + final Map cacheMapping = cached + .stream() + .collect(Collectors.toMap(q -> q.id, Function.identity())); + + if (!cacheMapping.keySet().containsAll(ids)) { + throw new RuntimeException("Not all requested quizzes cached"); + } + + return ids + .stream() + .map(id -> { + final QuizData q = cacheMapping.get(id); + return (q == null) + ? Result. ofError(new NoSuchElementException("Quiz with id: " + id)) + : Result.of(q); + }) + .collect(Collectors.toList()); + }); + } + + public Result> getQuizzes(final FilterMap filterMap) { + return this.allQuizzesSupplier.get() + .map(LmsAPIService.quizzesFilterFunction(filterMap)); + } + + protected abstract Supplier> allQuizzesSupplier(); + +} 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 bbea5b2c..714270b7 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 @@ -37,6 +37,7 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.dao.LmsSetupDAO; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPIService; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPITemplate; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.edx.OpenEdxLmsAPITemplateFactory; +import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.moodle.MoodleLmsAPITemplateFactory; @Lazy @Service @@ -49,16 +50,19 @@ public class LmsAPIServiceImpl implements LmsAPIService { private final ClientCredentialService clientCredentialService; private final WebserviceInfo webserviceInfo; private final OpenEdxLmsAPITemplateFactory openEdxLmsAPITemplateFactory; + private final MoodleLmsAPITemplateFactory moodleLmsAPITemplateFactory; private final Map cache = new ConcurrentHashMap<>(); public LmsAPIServiceImpl( final OpenEdxLmsAPITemplateFactory openEdxLmsAPITemplateFactory, + final MoodleLmsAPITemplateFactory moodleLmsAPITemplateFactory, final LmsSetupDAO lmsSetupDAO, final ClientCredentialService clientCredentialService, final WebserviceInfo webserviceInfo) { this.openEdxLmsAPITemplateFactory = openEdxLmsAPITemplateFactory; + this.moodleLmsAPITemplateFactory = moodleLmsAPITemplateFactory; this.lmsSetupDAO = lmsSetupDAO; this.clientCredentialService = clientCredentialService; this.webserviceInfo = webserviceInfo; @@ -227,6 +231,10 @@ public class LmsAPIServiceImpl implements LmsAPIService { return this.openEdxLmsAPITemplateFactory .create(lmsSetup, credentials, proxyData) .getOrThrow(); + case MOODLE: + return this.moodleLmsAPITemplateFactory + .create(lmsSetup, credentials, proxyData) + .getOrThrow(); default: throw new UnsupportedOperationException("No support for LMS Type: " + lmsSetup.lmsType); diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxCourseAccess.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxCourseAccess.java index 7a3d2599..26521021 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxCourseAccess.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxCourseAccess.java @@ -10,15 +10,10 @@ package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.edx; import java.net.URL; import java.util.ArrayList; -import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.NoSuchElementException; -import java.util.Set; -import java.util.function.Function; import java.util.function.Supplier; -import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; @@ -35,21 +30,21 @@ import org.springframework.security.oauth2.client.http.AccessTokenRequiredExcept import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails; import org.springframework.security.oauth2.common.OAuth2AccessToken; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + import ch.ethz.seb.sebserver.gbl.Constants; import ch.ethz.seb.sebserver.gbl.async.AsyncService; -import ch.ethz.seb.sebserver.gbl.async.MemoizingCircuitBreaker; import ch.ethz.seb.sebserver.gbl.model.exam.QuizData; import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup; import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult; import ch.ethz.seb.sebserver.gbl.util.Result; import ch.ethz.seb.sebserver.webservice.WebserviceInfo; -import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap; -import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPIService; +import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.CourseAccess; /** Implements the LmsAPITemplate for Open edX LMS Course API access. * * See also: https://course-catalog-api-guide.readthedocs.io */ -final class OpenEdxCourseAccess { +final class OpenEdxCourseAccess extends CourseAccess { private static final Logger log = LoggerFactory.getLogger(OpenEdxCourseAccess.class); @@ -59,7 +54,6 @@ final class OpenEdxCourseAccess { private final LmsSetup lmsSetup; private final OpenEdxRestTemplateFactory openEdxRestTemplateFactory; private final WebserviceInfo webserviceInfo; - private final MemoizingCircuitBreaker> allQuizzesSupplier; private OAuth2RestTemplate restTemplate; @@ -69,16 +63,10 @@ final class OpenEdxCourseAccess { final WebserviceInfo webserviceInfo, final AsyncService asyncService) { + super(asyncService); this.lmsSetup = lmsSetup; this.openEdxRestTemplateFactory = openEdxRestTemplateFactory; this.webserviceInfo = webserviceInfo; - this.allQuizzesSupplier = asyncService.createMemoizingCircuitBreaker( - allQuizzesSupplier(), - 3, - Constants.MINUTE_IN_MILLIS, - Constants.MINUTE_IN_MILLIS, - true, - Constants.HOUR_IN_MILLIS); } LmsSetupTestResult initAPIAccess() { @@ -114,51 +102,52 @@ final class OpenEdxCourseAccess { return LmsSetupTestResult.ofOkay(); } +// +// Result getQuizFromCache(final String id) { +// return Result.tryCatch(() -> { +// return this.allQuizzesSupplier +// .getChached() +// .stream() +// .filter(qd -> id.equals(qd.id)) +// .findFirst() +// .orElseThrow(() -> new NoSuchElementException("No cached quiz: " + id)); +// }); +// } +// +// Result>> getQuizzesFromCache(final Set ids) { +// return Result.tryCatch(() -> { +// final List cached = this.allQuizzesSupplier.getChached(); +// if (cached == null) { +// throw new RuntimeException("No cached quizzes"); +// } +// +// final Map cacheMapping = cached +// .stream() +// .collect(Collectors.toMap(q -> q.id, Function.identity())); +// +// if (!cacheMapping.keySet().containsAll(ids)) { +// throw new RuntimeException("Not all requested quizzes cached"); +// } +// +// return ids +// .stream() +// .map(id -> { +// final QuizData q = cacheMapping.get(id); +// return (q == null) +// ? Result. ofError(new NoSuchElementException("Quiz with id: " + id)) +// : Result.of(q); +// }) +// .collect(Collectors.toList()); +// }); +// } - Result getQuizFromCache(final String id) { - return Result.tryCatch(() -> { - return this.allQuizzesSupplier - .getChached() - .stream() - .filter(qd -> id.equals(qd.id)) - .findFirst() - .orElseThrow(() -> new NoSuchElementException("No cached quiz: " + id)); - }); - } +// Result> getQuizzes(final FilterMap filterMap) { +// return this.allQuizzesSupplier.get() +// .map(LmsAPIService.quizzesFilterFunction(filterMap)); +// } - Result>> getQuizzesFromCache(final Set ids) { - return Result.tryCatch(() -> { - final List cached = this.allQuizzesSupplier.getChached(); - if (cached == null) { - throw new RuntimeException("No cached quizzes"); - } - - final Map cacheMapping = cached - .stream() - .collect(Collectors.toMap(q -> q.id, Function.identity())); - - if (!cacheMapping.keySet().containsAll(ids)) { - throw new RuntimeException("Not all requested quizzes cached"); - } - - return ids - .stream() - .map(id -> { - final QuizData q = cacheMapping.get(id); - return (q == null) - ? Result. ofError(new NoSuchElementException("Quiz with id: " + id)) - : Result.of(q); - }) - .collect(Collectors.toList()); - }); - } - - Result> getQuizzes(final FilterMap filterMap) { - return this.allQuizzesSupplier.get() - .map(LmsAPIService.quizzesFilterFunction(filterMap)); - } - - private Supplier> allQuizzesSupplier() { + @Override + protected Supplier> allQuizzesSupplier() { return () -> { return getRestTemplate() .map(this::collectAllQuizzes) @@ -254,6 +243,7 @@ final class OpenEdxCourseAccess { } /** Maps a OpenEdX course API course page */ + @JsonIgnoreProperties(ignoreUnknown = true) static final class EdXPage { public Integer count; public String previous; @@ -263,6 +253,7 @@ final class OpenEdxCourseAccess { } /** Maps the OpenEdX course API course data */ + @JsonIgnoreProperties(ignoreUnknown = true) static final class CourseData { public String id; public String name; diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxCourseRestriction.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxCourseRestriction.java index 8d62bdf9..71b1f840 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxCourseRestriction.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxCourseRestriction.java @@ -114,6 +114,7 @@ public class OpenEdxCourseRestriction { final String url = this.lmsSetup.lmsApiUrl + getSebRestrictionUrl(courseId); final HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + httpHeaders.add(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate"); try { final OpenEdxSebRestriction data = this.restTemplate.exchange( @@ -199,6 +200,7 @@ public class OpenEdxCourseRestriction { final String url = this.lmsSetup.lmsApiUrl + getSebRestrictionUrl(courseId); final HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + httpHeaders.add(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate"); return () -> { final OpenEdxSebRestriction body = this.restTemplate.exchange( url, @@ -219,10 +221,12 @@ public class OpenEdxCourseRestriction { final String url = this.lmsSetup.lmsApiUrl + getSebRestrictionUrl(courseId); return () -> { + final HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.add(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate"); final ResponseEntity exchange = this.restTemplate.exchange( url, HttpMethod.DELETE, - new HttpEntity<>(new HttpHeaders()), + new HttpEntity<>(httpHeaders), Object.class); if (exchange.getStatusCode() == HttpStatus.NO_CONTENT) { diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxLmsAPITemplate.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxLmsAPITemplate.java index 6eb4d8ec..32711cdf 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxLmsAPITemplate.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/edx/OpenEdxLmsAPITemplate.java @@ -8,11 +8,11 @@ package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.edx; +import java.util.Arrays; import java.util.Collection; -import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Set; -import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -65,20 +65,11 @@ final class OpenEdxLmsAPITemplate implements LmsAPITemplate { return this.openEdxCourseAccess.getQuizzes(filterMap); } - @Override - public Collection> getQuizzes(final Set ids) { - return getQuizzes(new FilterMap()) - .getOrElse(() -> Collections.emptyList()) - .stream() - .filter(quiz -> ids.contains(quiz.id)) - .map(quiz -> Result.of(quiz)) - .collect(Collectors.toList()); - } - @Override public Result getQuizFromCache(final String id) { - return this.openEdxCourseAccess.getQuizFromCache(id) - .orElse(() -> getQuiz(id)); + return getQuizzesFromCache(new HashSet<>(Arrays.asList(id))) + .iterator() + .next(); } @Override diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/MoodleCourseAccess.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/MoodleCourseAccess.java new file mode 100644 index 00000000..2325d81d --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/MoodleCourseAccess.java @@ -0,0 +1,294 @@ +/* + * Copyright (c) 2020 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.moodle; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.LinkedMultiValueMap; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.type.TypeReference; + +import ch.ethz.seb.sebserver.gbl.api.JSONMapper; +import ch.ethz.seb.sebserver.gbl.async.AsyncService; +import ch.ethz.seb.sebserver.gbl.model.exam.QuizData; +import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup; +import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult; +import ch.ethz.seb.sebserver.gbl.util.Result; +import ch.ethz.seb.sebserver.gbl.util.Utils; +import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.CourseAccess; +import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.moodle.MoodleRestTemplateFactory.MoodleAPIRestTemplate; + +/** Implements the LmsAPITemplate for Open edX LMS Course API access. + * + * See also: https://docs.moodle.org/dev/Web_service_API_functions */ +public class MoodleCourseAccess extends CourseAccess { + + private static final Logger log = LoggerFactory.getLogger(MoodleCourseAccess.class); + + private static final String MOOLDE_QUIZ_START_URL_PATH = "/mod/quiz/view.php?id="; + private static final String MOODLE_COURSE_API_FUNCTION_NAME = "core_course_get_courses"; + private static final String MOODLE_QUIZ_API_FUNCTION_NAME = "mod_quiz_get_quizzes_by_courses"; + + private final JSONMapper jsonMapper; + private final LmsSetup lmsSetup; + private final MoodleRestTemplateFactory moodleRestTemplateFactory; + + private MoodleAPIRestTemplate restTemplate; + + protected MoodleCourseAccess( + final JSONMapper jsonMapper, + final LmsSetup lmsSetup, + final MoodleRestTemplateFactory moodleRestTemplateFactory, + final AsyncService asyncService) { + + super(asyncService); + this.jsonMapper = jsonMapper; + this.lmsSetup = lmsSetup; + this.moodleRestTemplateFactory = moodleRestTemplateFactory; + } + + LmsSetupTestResult initAPIAccess() { + + final LmsSetupTestResult attributesCheck = this.moodleRestTemplateFactory.test(); + if (!attributesCheck.isOk()) { + return attributesCheck; + } + + final Result restTemplateRequest = getRestTemplate(); + if (restTemplateRequest.hasError()) { + final String message = "Failed to gain access token from Moodle Rest API:\n tried token endpoints: " + + this.moodleRestTemplateFactory.knownTokenAccessPaths; + log.error(message, restTemplateRequest.getError().getMessage()); + return LmsSetupTestResult.ofTokenRequestError(message); + } + + final MoodleAPIRestTemplate restTemplate = restTemplateRequest.get(); + + try { + restTemplate.testAPIConnection( + MOODLE_COURSE_API_FUNCTION_NAME, + MOODLE_QUIZ_API_FUNCTION_NAME); + } catch (final RuntimeException e) { + log.error("Failed to access Open edX course API: ", e); + return LmsSetupTestResult.ofQuizAccessAPIError(e.getMessage()); + } + + return LmsSetupTestResult.ofOkay(); + } + + @Override + protected Supplier> allQuizzesSupplier() { + return () -> { + return getRestTemplate() + .map(this::collectAllQuizzes) + .getOrThrow(); + }; + } + + private ArrayList collectAllQuizzes(final MoodleAPIRestTemplate restTemplate) { + final String urlPrefix = this.lmsSetup.lmsApiUrl + MOOLDE_QUIZ_START_URL_PATH; + return collectAllCourses( + restTemplate) + .stream() + .reduce( + new ArrayList(), + (list, courseData) -> { + list.addAll(quizDataOf( + this.lmsSetup, + courseData, + urlPrefix)); + return list; + }, + (list1, list2) -> { + list1.addAll(list2); + return list1; + }); + } + + private List collectAllCourses(final MoodleAPIRestTemplate restTemplate) { + + try { + + // first get courses form Moodle... + final String coursesJSON = restTemplate.callMoodleAPIFunction(MOODLE_COURSE_API_FUNCTION_NAME); + final Map courseData = this.jsonMapper.> readValue( + coursesJSON, + new TypeReference>() { + }) + .stream() + .collect(Collectors.toMap(d -> d.id, Function.identity())); + + // then get all quizzes of courses and filter + final LinkedMultiValueMap attributes = new LinkedMultiValueMap<>(); + attributes.put("courseids", new ArrayList<>(courseData.keySet())); + + final String quizzesJSON = restTemplate.callMoodleAPIFunction( + MOODLE_QUIZ_API_FUNCTION_NAME, + attributes); + + final CourseQuizData courseQuizData = this.jsonMapper.readValue( + quizzesJSON, + CourseQuizData.class); + + courseQuizData.quizzes + .stream() + .forEach(quiz -> { + final CourseData course = courseData.get(quiz.course); + if (course != null) { + course.quizzes.add(quiz); + } + }); + + return courseData.values() + .stream() + .filter(c -> !c.quizzes.isEmpty()) + .collect(Collectors.toList()); + } catch (final Exception e) { + throw new RuntimeException("Unexpected exception while trying to get course data: ", e); + } + } + + private static List quizDataOf( + final LmsSetup lmsSetup, + final CourseData courseData, + final String uriPrefix) { + + final Map additionalAttrs = new HashMap<>(); + additionalAttrs.clear(); + additionalAttrs.put("timecreated", String.valueOf(courseData.timecreated)); + additionalAttrs.put("course_shortname", courseData.shortname); + additionalAttrs.put("course_fullname", courseData.fullname); + additionalAttrs.put("course_displayname", courseData.displayname); + additionalAttrs.put("course_summary", courseData.summary); + + return courseData.quizzes + .stream() + .map(courseQuizData -> { + final String startURI = uriPrefix + courseData.id; + additionalAttrs.put("coursemodule", courseQuizData.coursemodule); + additionalAttrs.put("timelimit", String.valueOf(courseQuizData.timelimit)); + return new QuizData( + courseQuizData.id, + lmsSetup.getInstitutionId(), + lmsSetup.id, + lmsSetup.getLmsType(), + courseQuizData.name, + courseQuizData.intro, + Utils.toDateTimeUTCUnix(courseData.startdate), + Utils.toDateTimeUTCUnix(courseData.enddate), + startURI, + additionalAttrs); + }) + .collect(Collectors.toList()); + + } + + private Result getRestTemplate() { + if (this.restTemplate == null) { + final Result templateRequest = this.moodleRestTemplateFactory + .createRestTemplate(); + if (templateRequest.hasError()) { + return templateRequest; + } else { + this.restTemplate = templateRequest.get(); + } + } + + return Result.of(this.restTemplate); + } + + /** Maps the Moodle course API course data */ + @JsonIgnoreProperties(ignoreUnknown = true) + static final class CourseData { + final String id; + final String shortname; + final String fullname; + final String displayname; + final String summary; + final Long startdate; // unixtime milliseconds UTC + final Long enddate; // unixtime milliseconds UTC + final Long timecreated; // unixtime milliseconds UTC + final Collection quizzes = new ArrayList<>(); + + @JsonCreator + protected CourseData( + @JsonProperty(value = "id") final String id, + @JsonProperty(value = "shortname") final String shortname, + @JsonProperty(value = "fullname") final String fullname, + @JsonProperty(value = "displayname") final String displayname, + @JsonProperty(value = "summary") final String summary, + @JsonProperty(value = "startdate") final Long startdate, + @JsonProperty(value = "enddate") final Long enddate, + @JsonProperty(value = "timecreated") final Long timecreated) { + + this.id = id; + this.shortname = shortname; + this.fullname = fullname; + this.displayname = displayname; + this.summary = summary; + this.startdate = startdate; + this.enddate = enddate; + this.timecreated = timecreated; + } + + } + + @JsonIgnoreProperties(ignoreUnknown = true) + static final class CourseQuizData { + final Collection quizzes; + + @JsonCreator + protected CourseQuizData( + @JsonProperty(value = "quizzes") final Collection quizzes) { + + this.quizzes = quizzes; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + static final class CourseQuiz { + final String id; + final String course; + final String coursemodule; + final String name; + final String intro; // HTML + final Long timelimit; // unixtime milliseconds UTC + + @JsonCreator + protected CourseQuiz( + @JsonProperty(value = "id") final String id, + @JsonProperty(value = "course") final String course, + @JsonProperty(value = "coursemodule") final String coursemodule, + @JsonProperty(value = "name") final String name, + @JsonProperty(value = "intro") final String intro, + @JsonProperty(value = "timelimit") final Long timelimit) { + + this.id = id; + this.course = course; + this.coursemodule = coursemodule; + this.name = name; + this.intro = intro; + this.timelimit = timelimit; + } + + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/MoodleLmsAPITemplate.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/MoodleLmsAPITemplate.java new file mode 100644 index 00000000..6587bdec --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/MoodleLmsAPITemplate.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2020 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.moodle; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +import ch.ethz.seb.sebserver.gbl.model.exam.Exam; +import ch.ethz.seb.sebserver.gbl.model.exam.QuizData; +import ch.ethz.seb.sebserver.gbl.model.exam.SebRestriction; +import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup; +import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult; +import ch.ethz.seb.sebserver.gbl.util.Result; +import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap; +import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPITemplate; + +public class MoodleLmsAPITemplate implements LmsAPITemplate { + + private final LmsSetup lmsSetup; + private final MoodleCourseAccess moodleCourseAccess; + + protected MoodleLmsAPITemplate( + final LmsSetup lmsSetup, + final MoodleCourseAccess moodleCourseAccess) { + + this.lmsSetup = lmsSetup; + this.moodleCourseAccess = moodleCourseAccess; + } + + @Override + public LmsSetup lmsSetup() { + return this.lmsSetup; + } + + @Override + public LmsSetupTestResult testCourseAccessAPI() { + return this.moodleCourseAccess.initAPIAccess(); + } + + @Override + public LmsSetupTestResult testCourseRestrictionAPI() { + return LmsSetupTestResult.ofQuizRestrictionAPIError("Not available yet"); + } + + @Override + public Result> getQuizzes(final FilterMap filterMap) { + return this.moodleCourseAccess.getQuizzes(filterMap); + } + + @Override + public Collection> getQuizzesFromCache(final Set ids) { + return this.moodleCourseAccess.getQuizzesFromCache(ids) + .getOrElse(() -> getQuizzes(ids)); + } + + @Override + public Result getQuizFromCache(final String id) { + return this.moodleCourseAccess.getQuizFromCache(id) + .orElse(() -> getQuiz(id)); + } + + @Override + public Result getSebClientRestriction(final Exam exam) { + throw new UnsupportedOperationException("SEB Restriction API not available yet"); + } + + @Override + public Result applySebClientRestriction( + final String externalExamId, + final SebRestriction sebRestrictionData) { + + throw new UnsupportedOperationException("SEB Restriction API not available yet"); + } + + @Override + public Result releaseSebClientRestriction(final Exam exam) { + throw new UnsupportedOperationException("SEB Restriction API not available yet"); + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/MoodleLmsAPITemplateFactory.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/MoodleLmsAPITemplateFactory.java new file mode 100644 index 00000000..0662933d --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/MoodleLmsAPITemplateFactory.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2020 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.moodle; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; + +import ch.ethz.seb.sebserver.ClientHttpRequestFactoryService; +import ch.ethz.seb.sebserver.gbl.Constants; +import ch.ethz.seb.sebserver.gbl.api.JSONMapper; +import ch.ethz.seb.sebserver.gbl.async.AsyncService; +import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup; +import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; +import ch.ethz.seb.sebserver.gbl.util.Result; +import ch.ethz.seb.sebserver.webservice.servicelayer.client.ClientCredentialService; +import ch.ethz.seb.sebserver.webservice.servicelayer.client.ClientCredentials; +import ch.ethz.seb.sebserver.webservice.servicelayer.client.ProxyData; + +@Lazy +@Service +@WebServiceProfile +public class MoodleLmsAPITemplateFactory { + + private final JSONMapper jsonMapper; + private final AsyncService asyncService; + private final ClientCredentialService clientCredentialService; + private final ClientHttpRequestFactoryService clientHttpRequestFactoryService; + private final String[] alternativeTokenRequestPaths; + + protected MoodleLmsAPITemplateFactory( + final JSONMapper jsonMapper, + final AsyncService asyncService, + final ClientCredentialService clientCredentialService, + final ClientHttpRequestFactoryService clientHttpRequestFactoryService, + @Value("${sebserver.webservice.lms.moodle.api.token.request.paths}") final String alternativeTokenRequestPaths) { + + this.jsonMapper = jsonMapper; + this.asyncService = asyncService; + this.clientCredentialService = clientCredentialService; + this.clientHttpRequestFactoryService = clientHttpRequestFactoryService; + this.alternativeTokenRequestPaths = (alternativeTokenRequestPaths != null) + ? StringUtils.split(alternativeTokenRequestPaths, Constants.LIST_SEPARATOR) + : null; + } + + public Result create( + final LmsSetup lmsSetup, + final ClientCredentials credentials, + final ProxyData proxyData) { + + return Result.tryCatch(() -> { + + final MoodleRestTemplateFactory moodleRestTemplateFactory = new MoodleRestTemplateFactory( + this.jsonMapper, + lmsSetup, + credentials, + proxyData, + this.clientCredentialService, + this.clientHttpRequestFactoryService, + this.alternativeTokenRequestPaths); + + final MoodleCourseAccess moodleCourseAccess = new MoodleCourseAccess( + this.jsonMapper, + lmsSetup, + moodleRestTemplateFactory, + this.asyncService); + + return new MoodleLmsAPITemplate( + lmsSetup, + moodleCourseAccess); + }); + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/MoodleRestTemplateFactory.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/MoodleRestTemplateFactory.java new file mode 100644 index 00000000..457475b2 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/moodle/MoodleRestTemplateFactory.java @@ -0,0 +1,391 @@ +/* + * Copyright (c) 2020 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.moodle; + +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import ch.ethz.seb.sebserver.ClientHttpRequestFactoryService; +import ch.ethz.seb.sebserver.gbl.api.APIMessage; +import ch.ethz.seb.sebserver.gbl.api.JSONMapper; +import ch.ethz.seb.sebserver.gbl.model.Domain.LMS_SETUP; +import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup; +import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult; +import ch.ethz.seb.sebserver.gbl.util.Result; +import ch.ethz.seb.sebserver.gbl.util.Utils; +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.client.ProxyData; + +final class MoodleRestTemplateFactory { + + final JSONMapper jsonMapper; + final LmsSetup lmsSetup; + final ClientCredentials credentials; + final ProxyData proxyData; + final ClientHttpRequestFactoryService clientHttpRequestFactoryService; + final ClientCredentialService clientCredentialService; + final Set knownTokenAccessPaths; + + public MoodleRestTemplateFactory( + final JSONMapper jsonMapper, + final LmsSetup lmsSetup, + final ClientCredentials credentials, + final ProxyData proxyData, + final ClientCredentialService clientCredentialService, + final ClientHttpRequestFactoryService clientHttpRequestFactoryService, + final String[] alternativeTokenRequestPaths) { + + this.jsonMapper = jsonMapper; + this.lmsSetup = lmsSetup; + this.clientCredentialService = clientCredentialService; + this.credentials = credentials; + this.proxyData = proxyData; + this.clientHttpRequestFactoryService = clientHttpRequestFactoryService; + + this.knownTokenAccessPaths = new HashSet<>(); + this.knownTokenAccessPaths.add(MoodleAPIRestTemplate.MOODLE_DEFAULT_TOKEN_REQUEST_PATH); + if (alternativeTokenRequestPaths != null) { + this.knownTokenAccessPaths.addAll(Arrays.asList(alternativeTokenRequestPaths)); + } + } + + public LmsSetupTestResult test() { + final List missingAttrs = new ArrayList<>(); + if (StringUtils.isBlank(this.lmsSetup.lmsApiUrl)) { + missingAttrs.add(APIMessage.fieldValidationError( + LMS_SETUP.ATTR_LMS_URL, + "lmsSetup:lmsUrl:notNull")); + } + + if (StringUtils.isBlank(this.lmsSetup.lmsRestApiToken)) { + if (!this.credentials.hasClientId()) { + missingAttrs.add(APIMessage.fieldValidationError( + LMS_SETUP.ATTR_LMS_CLIENTNAME, + "lmsSetup:lmsClientname:notNull")); + } + if (!this.credentials.hasSecret()) { + missingAttrs.add(APIMessage.fieldValidationError( + LMS_SETUP.ATTR_LMS_CLIENTSECRET, + "lmsSetup:lmsClientsecret:notNull")); + } + } + + if (!missingAttrs.isEmpty()) { + return LmsSetupTestResult.ofMissingAttributes(missingAttrs); + } + + return LmsSetupTestResult.ofOkay(); + } + + Result createRestTemplate() { + return this.knownTokenAccessPaths + .stream() + .map(this::createRestTemplate) + .filter(Result::hasValue) + .findFirst() + .orElse(Result.ofRuntimeError( + "Failed to gain any access on paths: " + this.knownTokenAccessPaths)); + } + + Result createRestTemplate(final String accessTokenPath) { + return Result.tryCatch(() -> { + final MoodleAPIRestTemplate template = createRestTemplate( + this.credentials, + accessTokenPath); + + final CharSequence accessToken = template.getAccessToken(); + if (accessToken == null) { + throw new RuntimeException("Failed to gain access token on path: " + accessTokenPath); + } + + return template; + }); + } + + private MoodleAPIRestTemplate createRestTemplate( + final ClientCredentials credentials, + final String accessTokenRequestPath) throws URISyntaxException { + + final CharSequence plainClientId = credentials.clientId; + final CharSequence plainClientSecret = this.clientCredentialService.getPlainClientSecret(credentials); + + final MoodleAPIRestTemplate restTemplate = new MoodleAPIRestTemplate( + this.jsonMapper, + this.lmsSetup.lmsApiUrl, + accessTokenRequestPath, + this.lmsSetup.lmsRestApiToken, + plainClientId, + plainClientSecret); + + final ClientHttpRequestFactory clientHttpRequestFactory = this.clientHttpRequestFactoryService + .getClientHttpRequestFactory(this.proxyData) + .getOrThrow(); + + restTemplate.setRequestFactory(clientHttpRequestFactory); + + return restTemplate; + } + + public final class MoodleAPIRestTemplate extends RestTemplate { + + public static final String URI_VAR_USER_NAME = "username"; + public static final String URI_VAR_PASSWORD = "pwd"; + public static final String URI_VAR_SERVICE = "service"; + + private static final String MOODLE_DEFAULT_TOKEN_REQUEST_PATH = + "/login/token.php?username={" + URI_VAR_USER_NAME + + "}&password={" + URI_VAR_PASSWORD + "}&service={" + URI_VAR_SERVICE + "}"; + + private static final String MOODLE_DEFAULT_REST_API_PATH = "/webservice/rest/server.php"; + private static final String REST_REQUEST_TOKEN_NAME = "wstoken"; + private static final String REST_REQUEST_FUNCTION_NAME = "wsfunction"; + private static final String REST_REQUEST_FORMAT_NAME = "moodlewsrestformat"; + private static final String REST_API_TEST_FUNCTION = "core_webservice_get_site_info"; + + private final String serverURL; + private final String tokenPath; + + private CharSequence accessToken = null; + + private final Map tokenReqURIVars; + private final HttpEntity tokenReqEntity = new HttpEntity<>(null); + + protected MoodleAPIRestTemplate( + + final JSONMapper jsonMapper, + final String serverURL, + final String tokenPath, + final CharSequence accessToken, + final CharSequence username, + final CharSequence password) { + + this.serverURL = serverURL; + this.tokenPath = tokenPath; + this.accessToken = StringUtils.isNotBlank(accessToken) ? accessToken : null; + + this.tokenReqURIVars = new HashMap<>(); + this.tokenReqURIVars.put(URI_VAR_USER_NAME, String.valueOf(username)); + this.tokenReqURIVars.put(URI_VAR_PASSWORD, String.valueOf(password)); + this.tokenReqURIVars.put(URI_VAR_SERVICE, "moodle_mobile_app"); + + } + + public String getService() { + return this.tokenReqURIVars.get(URI_VAR_SERVICE); + } + + public void setService(final String service) { + this.tokenReqURIVars.put(URI_VAR_SERVICE, service); + } + + public CharSequence getAccessToken() { + if (this.accessToken == null) { + requestAccessToken(); + } + + return this.accessToken; + } + + public void testAPIConnection(final String... functions) { + try { + final String apiInfo = this.callMoodleAPIFunction(REST_API_TEST_FUNCTION); + final WebserviceInfo webserviceInfo = + MoodleRestTemplateFactory.this.jsonMapper.readValue(apiInfo, WebserviceInfo.class); + + if (StringUtils.isBlank(webserviceInfo.username) || StringUtils.isBlank(webserviceInfo.userid)) { + throw new RuntimeException("Ivalid WebserviceInfo: " + webserviceInfo); + } + + final List missingAPIFunctions = Arrays.asList(functions) + .stream() + .filter(f -> !webserviceInfo.functions.containsKey(f)) + .collect(Collectors.toList()); + + if (!missingAPIFunctions.isEmpty()) { + throw new RuntimeException("Missing Moodle Webservice API functions: " + missingAPIFunctions); + } + + } catch (final RuntimeException re) { + throw re; + } catch (final Exception e) { + throw new RuntimeException("Failed to test Moodle rest API: ", e); + } + } + + public String callMoodleAPIFunction(final String functionName) { + return callMoodleAPIFunction(functionName, null); + } + + public String callMoodleAPIFunction( + final String functionName, + final MultiValueMap queryAttributes) { + + getAccessToken(); + + final UriComponentsBuilder queryParam = UriComponentsBuilder + .fromHttpUrl(this.serverURL + MOODLE_DEFAULT_REST_API_PATH) + .queryParam(REST_REQUEST_TOKEN_NAME, this.accessToken) + .queryParam(REST_REQUEST_FUNCTION_NAME, functionName) + .queryParam(REST_REQUEST_FORMAT_NAME, "json"); + + final boolean usePOST = queryAttributes != null && !queryAttributes.isEmpty(); + HttpEntity functionReqEntity; + if (usePOST) { + final HttpHeaders headers = new HttpHeaders(); + headers.set( + HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_FORM_URLENCODED_VALUE); + + final String body = Utils.toAppFormUrlEncodedBody(queryAttributes); + functionReqEntity = new HttpEntity<>(body, headers); + + } else { + functionReqEntity = new HttpEntity<>(null); + } + +// // NOTE: The interpretation of a multi-value GET parameter on a URL quesry part +// // seems to be very PHP specific. It must have the form of: +// // ... ¶m[]=x¶m[]=y& ... +// // And the square bracket must not be escaped on the URL like: %5B%5D +// String urlString = queryParam.toUriString() +// .replaceAll("%5B", "[") +// .replaceAll("%5D", "]"); + + final ResponseEntity response = super.exchange( + queryParam.toUriString(), + usePOST ? HttpMethod.POST : HttpMethod.GET, + functionReqEntity, + String.class); + + if (response.getStatusCode() != HttpStatus.OK) { + throw new RuntimeException( + "Failed to call Moodle webservice API function: " + functionName + " lms setup: " + + MoodleRestTemplateFactory.this.lmsSetup + " response: " + response.getBody()); + } + + final String body = response.getBody(); + // NOTE: for some unknown reason, Moodles API error responses come with a 200 OK response HTTP Status + // So this is a special Moodle specific error handling here... + if (body.startsWith("{exception")) { + throw new RuntimeException( + "Failed to call Moodle webservice API function: " + functionName + " lms setup: " + + MoodleRestTemplateFactory.this.lmsSetup + " response: " + body); + } + + return body; + } + + private void requestAccessToken() { + + final ResponseEntity response = super.exchange( + this.serverURL + this.tokenPath, + HttpMethod.GET, + this.tokenReqEntity, + String.class, + this.tokenReqURIVars); + + if (response.getStatusCode() != HttpStatus.OK) { + throw new RuntimeException("Failed to gain access token for LMS (Moodle): lmsSetup: " + + MoodleRestTemplateFactory.this.lmsSetup + " response: " + response.getBody()); + } + + try { + final MoodleToken moodleToken = MoodleRestTemplateFactory.this.jsonMapper.readValue( + response.getBody(), + MoodleToken.class); + + this.accessToken = moodleToken.token; + } catch (final Exception e) { + throw new RuntimeException("Failed to gain access token for LMS (Moodle): lmsSetup: " + + MoodleRestTemplateFactory.this.lmsSetup + " response: " + response.getBody(), e); + } + } + + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private final static class MoodleToken { + final String token; + @SuppressWarnings("unused") + final String privatetoken; + + @JsonCreator + protected MoodleToken( + @JsonProperty(value = "token") final String token, + @JsonProperty(value = "privatetoken", required = false) final String privatetoken) { + + this.token = token; + this.privatetoken = privatetoken; + } + + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private final static class WebserviceInfo { + String username; + String userid; + Map functions; + + @JsonCreator + protected WebserviceInfo( + @JsonProperty(value = "username") final String username, + @JsonProperty(value = "userid") final String userid, + @JsonProperty(value = "functions") final Collection functions) { + + this.username = username; + this.userid = userid; + this.functions = functions + .stream() + .collect(Collectors.toMap(fi -> fi.name, Function.identity())); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private final static class FunctionInfo { + String name; + @SuppressWarnings("unused") + String version; + + @JsonCreator + protected FunctionInfo( + @JsonProperty(value = "name") final String name, + @JsonProperty(value = "version") final String version) { + + this.name = name; + this.version = version; + } + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionControlTask.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionControlTask.java index c4996c93..7f455313 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionControlTask.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionControlTask.java @@ -158,7 +158,7 @@ class ExamSessionControlTask implements DisposableBean { final Map updated = this.examDAO.allForEndCheck() .getOrThrow() .stream() - .filter(exam -> exam.endTime.plus(this.examTimeSuffix).isBefore(now)) + .filter(exam -> exam.endTime != null && exam.endTime.plus(this.examTimeSuffix).isBefore(now)) .map(exam -> this.examUpdateHandler.setFinished(exam, updateId)) .collect(Collectors.toMap(Exam::getId, Exam::getName)); diff --git a/src/main/resources/config/application-dev-ws.properties b/src/main/resources/config/application-dev-ws.properties index ad27466f..568065da 100644 --- a/src/main/resources/config/application-dev-ws.properties +++ b/src/main/resources/config/application-dev-ws.properties @@ -17,7 +17,7 @@ spring.datasource.hikari.maxLifetime=1800000 sebserver.http.client.connect-timeout=15000 sebserver.http.client.connection-request-timeout=10000 -sebserver.http.client.read-timeout=10000 +sebserver.http.client.read-timeout=20000 # webservice configuration sebserver.init.adminaccount.gen-on-init=false @@ -43,6 +43,7 @@ sebserver.webservice.api.exam.enable-indicator-cache=true sebserver.webservice.api.pagination.maxPageSize=500 # comma separated list of known possible OpenEdX API access token request endpoints sebserver.webservice.lms.openedx.api.token.request.paths=/oauth2/access_token +sebserver.webservice.lms.moodle.api.token.request.paths= sebserver.webservice.lms.address.alias=lms.mockup.com=lms.address.alias # NOTE: This is a temporary work-around for SEB Restriction API within Open edX SEB integration plugin to diff --git a/src/main/resources/static/css/sebserver.css b/src/main/resources/static/css/sebserver.css index 7d02a48a..1a33b507 100644 --- a/src/main/resources/static/css/sebserver.css +++ b/src/main/resources/static/css/sebserver.css @@ -1,6 +1,6 @@ * { - color: #000000; - font: normal 12px Arial, Helvetica, sans-serif; + font: 12px Arial, Helvetica, sans-serif; + color: #4a4a4a; background-image: none; background-color: #FFFFFF; padding: 0;