SEBSERV-75 implementation
This commit is contained in:
parent
8ab3ddf725
commit
e5f5bc5c02
25 changed files with 1184 additions and 112 deletions
|
@ -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 = '%';
|
||||
|
|
|
@ -75,7 +75,6 @@ public final class CircuitBreaker<T> {
|
|||
*
|
||||
* @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<T> {
|
|||
|
||||
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<T> {
|
|||
}
|
||||
|
||||
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()) {
|
||||
|
|
|
@ -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> features;
|
||||
|
||||
|
|
|
@ -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<String, String> attributes) {
|
||||
return attributes
|
||||
.entrySet()
|
||||
.stream()
|
||||
.reduce(
|
||||
new StringBuilder(),
|
||||
(sb, entry) -> {
|
||||
final String name = entry.getKey();
|
||||
final List<String> 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<String> 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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -257,9 +257,10 @@ public class QuizDiscoveryList implements TemplateComposer {
|
|||
|
||||
action.getSingleSelection();
|
||||
|
||||
final ModalInputDialog<Void> dialog = new ModalInputDialog<>(
|
||||
final ModalInputDialog<Void> dialog = new ModalInputDialog<Void>(
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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();}
|
||||
|
|
|
@ -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<String> {
|
||||
|
||||
private static final String HTML_TEXT_BLOCK_START =
|
||||
"<span style=\"font: 12px Arial, Helvetica, sans-serif;color: #4a4a4a;\">";
|
||||
private static final String HTML_TEXT_BLOCK_END = "</span>";
|
||||
|
||||
boolean isPassword = false;
|
||||
boolean isNumber = false;
|
||||
Consumer<String> 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<String> {
|
|||
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<String> {
|
|||
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)
|
||||
|
|
|
@ -194,7 +194,7 @@ public interface PageService {
|
|||
* @param <T> the type of the Entity of the table
|
||||
* @return TableBuilder of specified type */
|
||||
default <T extends Entity> TableBuilder<T> entityTableBuilder(final RestCall<Page<T>> apiCall) {
|
||||
return entityTableBuilder(apiCall.getEntityType().name(), apiCall);
|
||||
return entityTableBuilder(apiCall.getClass().getSimpleName(), apiCall);
|
||||
}
|
||||
|
||||
/** Get an new TableBuilder for specified page based RestCall.
|
||||
|
|
|
@ -213,7 +213,7 @@ public class EntityTable<ROW extends Entity> {
|
|||
this.navigator = new TableNavigator(this);
|
||||
|
||||
createTableColumns();
|
||||
initCurrentPageFromUserAttr();
|
||||
this.pageNumber = initCurrentPageFromUserAttr();
|
||||
initFilterFromUserAttrs();
|
||||
initSortFromUserAttr();
|
||||
updateTableRows(
|
||||
|
@ -269,7 +269,7 @@ public class EntityTable<ROW extends Entity> {
|
|||
this.sortColumn,
|
||||
this.sortOrder);
|
||||
|
||||
updateCurrentPageAttr(pageSelection);
|
||||
updateCurrentPageAttr();
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
|
@ -282,14 +282,8 @@ public class EntityTable<ROW extends Entity> {
|
|||
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<ROW extends Entity> {
|
|||
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<ROW extends Entity> {
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -149,7 +149,7 @@ public interface LmsAPIService {
|
|||
}
|
||||
|
||||
return new Page<>(
|
||||
(quizzes.size() / pageSize) + 1,
|
||||
(quizzes.size() / pageSize),
|
||||
pageNumber,
|
||||
sortAttribute,
|
||||
quizzes.subList(start, end));
|
||||
|
|
|
@ -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<Result<QuizData>> getQuizzes(Set<String> ids);
|
||||
default Collection<Result<QuizData>> getQuizzes(final Set<String> 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,
|
||||
|
|
|
@ -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<List<QuizData>> 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<QuizData> 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<Collection<Result<QuizData>>> getQuizzesFromCache(final Set<String> ids) {
|
||||
return Result.tryCatch(() -> {
|
||||
final List<QuizData> cached = this.allQuizzesSupplier.getChached();
|
||||
if (cached == null) {
|
||||
throw new RuntimeException("No cached quizzes");
|
||||
}
|
||||
|
||||
final Map<String, QuizData> 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.<QuizData> ofError(new NoSuchElementException("Quiz with id: " + id))
|
||||
: Result.of(q);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
});
|
||||
}
|
||||
|
||||
public Result<List<QuizData>> getQuizzes(final FilterMap filterMap) {
|
||||
return this.allQuizzesSupplier.get()
|
||||
.map(LmsAPIService.quizzesFilterFunction(filterMap));
|
||||
}
|
||||
|
||||
protected abstract Supplier<List<QuizData>> allQuizzesSupplier();
|
||||
|
||||
}
|
|
@ -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<CacheKey, LmsAPITemplate> 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);
|
||||
|
|
|
@ -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<List<QuizData>> 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<QuizData> 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<Collection<Result<QuizData>>> getQuizzesFromCache(final Set<String> ids) {
|
||||
// return Result.tryCatch(() -> {
|
||||
// final List<QuizData> cached = this.allQuizzesSupplier.getChached();
|
||||
// if (cached == null) {
|
||||
// throw new RuntimeException("No cached quizzes");
|
||||
// }
|
||||
//
|
||||
// final Map<String, QuizData> 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.<QuizData> ofError(new NoSuchElementException("Quiz with id: " + id))
|
||||
// : Result.of(q);
|
||||
// })
|
||||
// .collect(Collectors.toList());
|
||||
// });
|
||||
// }
|
||||
|
||||
Result<QuizData> 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<List<QuizData>> getQuizzes(final FilterMap filterMap) {
|
||||
// return this.allQuizzesSupplier.get()
|
||||
// .map(LmsAPIService.quizzesFilterFunction(filterMap));
|
||||
// }
|
||||
|
||||
Result<Collection<Result<QuizData>>> getQuizzesFromCache(final Set<String> ids) {
|
||||
return Result.tryCatch(() -> {
|
||||
final List<QuizData> cached = this.allQuizzesSupplier.getChached();
|
||||
if (cached == null) {
|
||||
throw new RuntimeException("No cached quizzes");
|
||||
}
|
||||
|
||||
final Map<String, QuizData> 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.<QuizData> ofError(new NoSuchElementException("Quiz with id: " + id))
|
||||
: Result.of(q);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
});
|
||||
}
|
||||
|
||||
Result<List<QuizData>> getQuizzes(final FilterMap filterMap) {
|
||||
return this.allQuizzesSupplier.get()
|
||||
.map(LmsAPIService.quizzesFilterFunction(filterMap));
|
||||
}
|
||||
|
||||
private Supplier<List<QuizData>> allQuizzesSupplier() {
|
||||
@Override
|
||||
protected Supplier<List<QuizData>> 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;
|
||||
|
|
|
@ -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<Object> exchange = this.restTemplate.exchange(
|
||||
url,
|
||||
HttpMethod.DELETE,
|
||||
new HttpEntity<>(new HttpHeaders()),
|
||||
new HttpEntity<>(httpHeaders),
|
||||
Object.class);
|
||||
|
||||
if (exchange.getStatusCode() == HttpStatus.NO_CONTENT) {
|
||||
|
|
|
@ -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<Result<QuizData>> getQuizzes(final Set<String> 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<QuizData> getQuizFromCache(final String id) {
|
||||
return this.openEdxCourseAccess.getQuizFromCache(id)
|
||||
.orElse(() -> getQuiz(id));
|
||||
return getQuizzesFromCache(new HashSet<>(Arrays.asList(id)))
|
||||
.iterator()
|
||||
.next();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -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<MoodleAPIRestTemplate> 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<List<QuizData>> allQuizzesSupplier() {
|
||||
return () -> {
|
||||
return getRestTemplate()
|
||||
.map(this::collectAllQuizzes)
|
||||
.getOrThrow();
|
||||
};
|
||||
}
|
||||
|
||||
private ArrayList<QuizData> collectAllQuizzes(final MoodleAPIRestTemplate restTemplate) {
|
||||
final String urlPrefix = this.lmsSetup.lmsApiUrl + MOOLDE_QUIZ_START_URL_PATH;
|
||||
return collectAllCourses(
|
||||
restTemplate)
|
||||
.stream()
|
||||
.reduce(
|
||||
new ArrayList<QuizData>(),
|
||||
(list, courseData) -> {
|
||||
list.addAll(quizDataOf(
|
||||
this.lmsSetup,
|
||||
courseData,
|
||||
urlPrefix));
|
||||
return list;
|
||||
},
|
||||
(list1, list2) -> {
|
||||
list1.addAll(list2);
|
||||
return list1;
|
||||
});
|
||||
}
|
||||
|
||||
private List<CourseData> collectAllCourses(final MoodleAPIRestTemplate restTemplate) {
|
||||
|
||||
try {
|
||||
|
||||
// first get courses form Moodle...
|
||||
final String coursesJSON = restTemplate.callMoodleAPIFunction(MOODLE_COURSE_API_FUNCTION_NAME);
|
||||
final Map<String, CourseData> courseData = this.jsonMapper.<Collection<CourseData>> readValue(
|
||||
coursesJSON,
|
||||
new TypeReference<Collection<CourseData>>() {
|
||||
})
|
||||
.stream()
|
||||
.collect(Collectors.toMap(d -> d.id, Function.identity()));
|
||||
|
||||
// then get all quizzes of courses and filter
|
||||
final LinkedMultiValueMap<String, String> 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<QuizData> quizDataOf(
|
||||
final LmsSetup lmsSetup,
|
||||
final CourseData courseData,
|
||||
final String uriPrefix) {
|
||||
|
||||
final Map<String, String> 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<MoodleAPIRestTemplate> getRestTemplate() {
|
||||
if (this.restTemplate == null) {
|
||||
final Result<MoodleAPIRestTemplate> 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<CourseQuiz> 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<CourseQuiz> quizzes;
|
||||
|
||||
@JsonCreator
|
||||
protected CourseQuizData(
|
||||
@JsonProperty(value = "quizzes") final Collection<CourseQuiz> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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<List<QuizData>> getQuizzes(final FilterMap filterMap) {
|
||||
return this.moodleCourseAccess.getQuizzes(filterMap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Result<QuizData>> getQuizzesFromCache(final Set<String> ids) {
|
||||
return this.moodleCourseAccess.getQuizzesFromCache(ids)
|
||||
.getOrElse(() -> getQuizzes(ids));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<QuizData> getQuizFromCache(final String id) {
|
||||
return this.moodleCourseAccess.getQuizFromCache(id)
|
||||
.orElse(() -> getQuiz(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<SebRestriction> getSebClientRestriction(final Exam exam) {
|
||||
throw new UnsupportedOperationException("SEB Restriction API not available yet");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<SebRestriction> applySebClientRestriction(
|
||||
final String externalExamId,
|
||||
final SebRestriction sebRestrictionData) {
|
||||
|
||||
throw new UnsupportedOperationException("SEB Restriction API not available yet");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<Exam> releaseSebClientRestriction(final Exam exam) {
|
||||
throw new UnsupportedOperationException("SEB Restriction API not available yet");
|
||||
}
|
||||
|
||||
}
|
|
@ -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<MoodleLmsAPITemplate> 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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -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<String> 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<APIMessage> 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<MoodleAPIRestTemplate> 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<MoodleAPIRestTemplate> 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<String, String> 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<String> 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<String, String> 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<String> 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<String> 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<String, FunctionInfo> functions;
|
||||
|
||||
@JsonCreator
|
||||
protected WebserviceInfo(
|
||||
@JsonProperty(value = "username") final String username,
|
||||
@JsonProperty(value = "userid") final String userid,
|
||||
@JsonProperty(value = "functions") final Collection<FunctionInfo> 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -158,7 +158,7 @@ class ExamSessionControlTask implements DisposableBean {
|
|||
final Map<Long, String> 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));
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue