SEBSERV-75 implementation

This commit is contained in:
anhefti 2020-01-21 16:27:04 +01:00
parent 8ab3ddf725
commit e5f5bc5c02
25 changed files with 1184 additions and 112 deletions

View file

@ -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 = '%';

View file

@ -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()) {

View file

@ -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;

View file

@ -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();
}
}

View file

@ -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))

View file

@ -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();
}
}

View file

@ -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();}

View file

@ -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)

View file

@ -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.

View file

@ -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;
}
}

View file

@ -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;

View file

@ -149,7 +149,7 @@ public interface LmsAPIService {
}
return new Page<>(
(quizzes.size() / pageSize) + 1,
(quizzes.size() / pageSize),
pageNumber,
sortAttribute,
quizzes.subList(start, end));

View file

@ -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,

View file

@ -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();
}

View file

@ -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);

View file

@ -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;

View file

@ -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) {

View file

@ -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

View file

@ -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;
}
}
}

View file

@ -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");
}
}

View file

@ -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);
});
}
}

View file

@ -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:
// // ... &param[]=x&param[]=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;
}
}
}

View file

@ -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));

View file

@ -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

View file

@ -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;