diff --git a/pom.xml b/pom.xml index 82f3e9d6..1f6e7337 100644 --- a/pom.xml +++ b/pom.xml @@ -18,7 +18,7 @@ jar - 1.2.3 + 1.2.4 ${sebserver-version} ${sebserver-version} UTF-8 diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/Constants.java b/src/main/java/ch/ethz/seb/sebserver/gbl/Constants.java index 2ab1481f..8f026546 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/Constants.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/Constants.java @@ -24,6 +24,8 @@ import ch.ethz.seb.sebserver.gbl.api.authorization.Privilege; /** Global Constants used in SEB Server web-service as well as in web-gui component */ public final class Constants { + public static final String FILE_EXT_CSV = ".csv"; + public static final String DEFAULT_LANG_CODE = "en"; public static final String DEFAULT_TIME_ZONE_CODE = "UTC"; public static final String TOOLTIP_TEXT_KEY_SUFFIX = ".tooltip"; diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java b/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java index e523be9a..6a37a5f5 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java @@ -197,6 +197,10 @@ public final class API { public static final String SEB_CLIENT_EVENT_ENDPOINT = "/seb-client-event"; public static final String SEB_CLIENT_EVENT_SEARCH_PATH_SEGMENT = "/search"; + public static final String SEB_CLIENT_EVENT_EXPORT_PATH_SEGMENT = "/export"; + public static final String SEB_CLIENT_EVENT_EXPORT_TYPE = "exportType"; + public static final String SEB_CLIENT_EVENT_EXPORT_INCLUDE_CONNECTIONS = "includeConnectionDetails"; + public static final String SEB_CLIENT_EVENT_EXPORT_INCLUDE_EXAMS = "includeExamDetails"; public static final String SEB_CLIENT_EVENT_EXTENDED_PAGE_ENDPOINT = SEB_CLIENT_EVENT_ENDPOINT + SEB_CLIENT_EVENT_SEARCH_PATH_SEGMENT; diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/Indicator.java b/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/Indicator.java index 6446140a..de2ce9bd 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/Indicator.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/model/exam/Indicator.java @@ -128,9 +128,9 @@ public final class Indicator implements Entity { this.thresholds = Utils.immutableListOf(thresholds); } - public Indicator(final Exam exam, final POSTMapper postParams) { + public Indicator(final Long examId, final POSTMapper postParams) { this.id = null; - this.examId = exam.id; + this.examId = examId; this.name = postParams.getString(Domain.INDICATOR.ATTR_NAME); this.type = postParams.getEnum(Domain.INDICATOR.ATTR_TYPE, IndicatorType.class); this.defaultColor = postParams.getString(Domain.INDICATOR.ATTR_COLOR); diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/model/session/ClientEvent.java b/src/main/java/ch/ethz/seb/sebserver/gbl/model/session/ClientEvent.java index 535c07c2..886bd5ed 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/model/session/ClientEvent.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/model/session/ClientEvent.java @@ -68,6 +68,10 @@ public class ClientEvent implements Entity, IndicatorValueHolder { } } + public enum ExportType { + CSV + } + @JsonProperty(Domain.CLIENT_EVENT.ATTR_ID) public final Long id; diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/util/Result.java b/src/main/java/ch/ethz/seb/sebserver/gbl/util/Result.java index 5eba6fe6..fb67d943 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/util/Result.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/util/Result.java @@ -262,6 +262,13 @@ public final class Result { } } + public Result onSuccess(final Consumer handler) { + if (this.error == null) { + handler.accept(this.value); + } + return this; + } + /** Uses a given error handler to apply an error if there is one and returning itself again * for further processing. * diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/util/Utils.java b/src/main/java/ch/ethz/seb/sebserver/gbl/util/Utils.java index 8d79797b..4d0c4671 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/util/Utils.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/util/Utils.java @@ -666,4 +666,19 @@ public final class Utils { } } + public static CharSequence trim(final CharSequence sequence) { + if (sequence == null) { + return null; + } + + return StringUtils.trim(sequence.toString()); + } + + public static String toCSVString(final String text) { + if (StringUtils.isBlank(text)) { + return StringUtils.EMPTY; + } + return Constants.DOUBLE_QUOTE + text.replace("\"", "\"\"") + Constants.DOUBLE_QUOTE; + } + } diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/ProctorRoomConnectionsPopup.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/ProctorRoomConnectionsPopup.java index 87a367eb..46139f15 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/ProctorRoomConnectionsPopup.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/ProctorRoomConnectionsPopup.java @@ -75,23 +75,25 @@ public class ProctorRoomConnectionsPopup { .call() .getOrThrow()); - this.pageService.staticListTableBuilder(connections, EntityType.CLIENT_CONNECTION) + final EntityTable compose = + this.pageService.staticListTableBuilder(connections, EntityType.CLIENT_CONNECTION) - .withEmptyMessage(EMPTY_LIST_TEXT_KEY) - .withPaging(10) + .withEmptyMessage(EMPTY_LIST_TEXT_KEY) + .withPaging(10) - .withColumn(new ColumnDefinition<>( - Domain.CLIENT_CONNECTION.ATTR_EXAM_USER_SESSION_ID, - TABLE_COLUMN_NAME, - ClientConnection::getUserSessionId)) + .withColumn(new ColumnDefinition<>( + Domain.CLIENT_CONNECTION.ATTR_EXAM_USER_SESSION_ID, + TABLE_COLUMN_NAME, + ClientConnection::getUserSessionId)) - .withDefaultAction(t -> actionBuilder - .newAction(ActionDefinition.MONITOR_EXAM_CLIENT_CONNECTION) - .withParentEntityKey(parentEntityKey) - .withExec(action -> showClientConnection(action, dialog, t)) - .create()) + .withDefaultAction(t -> actionBuilder + .newAction(ActionDefinition.MONITOR_EXAM_CLIENT_CONNECTION) + .withParentEntityKey(parentEntityKey) + .withExec(action -> showClientConnection(action, dialog, t)) + .create()) - .compose(pageContext); + .compose(pageContext); + compose.reset(); } private PageAction showClientConnection( diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/SEBClientEvents.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/SEBClientEvents.java index bc303c35..58d65b91 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/SEBClientEvents.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/SEBClientEvents.java @@ -8,6 +8,7 @@ package ch.ethz.seb.sebserver.gui.content; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -15,6 +16,8 @@ import java.util.function.Function; import java.util.stream.Collectors; import org.apache.tomcat.util.buf.StringUtils; +import org.eclipse.rap.rwt.RWT; +import org.eclipse.rap.rwt.client.service.UrlLauncher; import org.eclipse.swt.widgets.Composite; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,12 +27,15 @@ import org.springframework.stereotype.Component; import org.springframework.util.MultiValueMap; import ch.ethz.seb.sebserver.gbl.Constants; +import ch.ethz.seb.sebserver.gbl.api.API; import ch.ethz.seb.sebserver.gbl.api.EntityType; import ch.ethz.seb.sebserver.gbl.api.authorization.PrivilegeType; import ch.ethz.seb.sebserver.gbl.model.Domain; import ch.ethz.seb.sebserver.gbl.model.EntityName; +import ch.ethz.seb.sebserver.gbl.model.Page; import ch.ethz.seb.sebserver.gbl.model.session.ClientConnection; import ch.ethz.seb.sebserver.gbl.model.session.ClientEvent; +import ch.ethz.seb.sebserver.gbl.model.session.ClientEvent.ExportType; import ch.ethz.seb.sebserver.gbl.model.session.ExtendedClientEvent; import ch.ethz.seb.sebserver.gbl.profile.GuiProfile; import ch.ethz.seb.sebserver.gbl.util.Utils; @@ -42,6 +48,8 @@ import ch.ethz.seb.sebserver.gui.service.page.PageService; import ch.ethz.seb.sebserver.gui.service.page.PageService.PageActionBuilder; import ch.ethz.seb.sebserver.gui.service.page.TemplateComposer; import ch.ethz.seb.sebserver.gui.service.page.impl.PageAction; +import ch.ethz.seb.sebserver.gui.service.remote.download.DownloadService; +import ch.ethz.seb.sebserver.gui.service.remote.download.SEBClientLogExport; import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.RestService; import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.logs.GetClientEventNames; import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.logs.GetExtendedClientEventPage; @@ -84,23 +92,29 @@ public class SEBClientEvents implements TemplateComposer { private final ResourceService resourceService; private final RestService restService; private final I18nSupport i18nSupport; + private final DownloadService downloadService; private final SEBClientEventDetailsPopup sebClientEventDetailsPopup; private final SEBClientEventDeletePopup sebClientEventDeletePopup; private final int pageSize; + private final String exportFileName; public SEBClientEvents( final PageService pageService, + final DownloadService downloadService, final SEBClientEventDetailsPopup sebClientEventDetailsPopup, final SEBClientEventDeletePopup sebClientEventDeletePopup, + @Value("${sebserver.gui.seb.client.logs.export.filename:SEBClientLogs}") final String exportFileName, @Value("${sebserver.gui.list.page.size:20}") final Integer pageSize) { this.pageService = pageService; + this.downloadService = downloadService; this.resourceService = pageService.getResourceService(); this.restService = this.resourceService.getRestService(); this.i18nSupport = this.resourceService.getI18nSupport(); this.sebClientEventDetailsPopup = sebClientEventDetailsPopup; this.sebClientEventDeletePopup = sebClientEventDeletePopup; this.pageSize = pageSize; + this.exportFileName = exportFileName; this.examFilter = new TableFilterAttribute( CriteriaType.SINGLE_SELECTION, @@ -219,12 +233,52 @@ public class SEBClientEvents implements TemplateComposer { .noEventPropagation() .publish(false) + .newAction(ActionDefinition.LOGS_SEB_CLIENT_EXPORT_CSV) + .withExec(action -> this.exportLogs(action, ExportType.CSV, table)) + .noEventPropagation() + .publishIf(() -> writeGrant, table.hasAnyContent()) + .newAction(ActionDefinition.LOGS_SEB_CLIENT_DELETE_ALL) .withExec(action -> this.getOpenDelete(action, table.getFilterCriteria())) .noEventPropagation() .publishIf(() -> writeGrant, table.hasAnyContent()); } + private PageAction exportLogs( + final PageAction action, + final ExportType type, + final EntityTable table) { + + try { + + final UrlLauncher urlLauncher = RWT.getClient().getService(UrlLauncher.class); + final String fileName = this.exportFileName + + Constants.UNDERLINE + + this.i18nSupport.formatDisplayDate(Utils.getMillisecondsNow()) + .replace(" ", "_") + .replace(".", "_") + + Constants.FILE_EXT_CSV; + final Map queryAttrs = new HashMap<>(); + + queryAttrs.put(API.SEB_CLIENT_EVENT_EXPORT_TYPE, type.name()); + final String sortAttr = table.getSortOrder().encode(table.getSortColumn()); + queryAttrs.put(Page.ATTR_SORT, sortAttr); + table.getFilterCriteria().forEach((name, value) -> queryAttrs.put(name, value.get(0))); + + final String downloadURL = this.downloadService + .createDownloadURL( + SEBClientLogExport.class, + fileName, + queryAttrs); + + urlLauncher.openURL(downloadURL); + } catch (final Exception e) { + log.error("Failed open export log download: ", e); + } + + return action; + } + private PageAction getOpenDelete( final PageAction pageAction, final MultiValueMap filterCriteria) { diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/action/ActionDefinition.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/action/ActionDefinition.java index 759b47e1..b21f098c 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/action/ActionDefinition.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/action/ActionDefinition.java @@ -770,6 +770,10 @@ public enum ActionDefinition { ImageIcon.DELETE, PageStateDefinitionImpl.SEB_CLIENT_LOGS, ActionCategory.LOGS_SEB_CLIENT_LIST), + LOGS_SEB_CLIENT_EXPORT_CSV( + new LocTextKey("sebserver.seblogs.action.export.csv"), + ImageIcon.EXPORT, + ActionCategory.LOGS_SEB_CLIENT_LIST), ; diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/AbstractDownloadServiceHandler.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/AbstractDownloadServiceHandler.java index 593a4029..d78880a4 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/AbstractDownloadServiceHandler.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/AbstractDownloadServiceHandler.java @@ -40,17 +40,8 @@ public abstract class AbstractDownloadServiceHandler implements DownloadServiceH log.debug("download requested... trying to get needed parameter from request"); final String modelId = request.getParameter(API.PARAM_MODEL_ID); - if (StringUtils.isBlank(modelId)) { - log.error( - "Mandatory modelId parameter not found within HttpServletRequest. Download request is ignored"); - return; - } - if (log.isDebugEnabled()) { - log.debug( - "Found modelId: {} for {} download. Trying to request webservice...", - modelId, - downloadFileName); + log.debug("Found modelId: {} for {} download.", modelId); } final String parentModelId = request.getParameter(API.PARAM_PARENT_MODEL_ID); @@ -67,7 +58,7 @@ public abstract class AbstractDownloadServiceHandler implements DownloadServiceH response.setHeader(HttpHeaders.CONTENT_DISPOSITION, header); response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE); - webserviceCall(modelId, parentModelId, response.getOutputStream()); + webserviceCall(modelId, parentModelId, response.getOutputStream(), request); } catch (final Exception e) { log.error( @@ -76,6 +67,10 @@ public abstract class AbstractDownloadServiceHandler implements DownloadServiceH } } - protected abstract void webserviceCall(String modelId, String parentModelId, OutputStream downloadOut); + protected abstract void webserviceCall( + String modelId, + String parentModelId, + OutputStream downloadOut, + HttpServletRequest request); } diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/DownloadService.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/DownloadService.java index 5ba0c74f..e9b2b64f 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/DownloadService.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/DownloadService.java @@ -8,9 +8,14 @@ package ch.ethz.seb.sebserver.gui.service.remote.download; -import ch.ethz.seb.sebserver.gbl.Constants; -import ch.ethz.seb.sebserver.gbl.api.API; -import ch.ethz.seb.sebserver.gbl.profile.GuiProfile; +import java.util.Collection; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + import org.apache.commons.lang3.StringUtils; import org.eclipse.rap.rwt.RWT; import org.eclipse.rap.rwt.service.ServiceHandler; @@ -19,12 +24,10 @@ import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.util.Collection; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; +import ch.ethz.seb.sebserver.gbl.Constants; +import ch.ethz.seb.sebserver.gbl.api.API; +import ch.ethz.seb.sebserver.gbl.profile.GuiProfile; +import ch.ethz.seb.sebserver.gbl.util.Utils; /** Implements a eclipse RAP ServiceHandler to handle downloads */ @Lazy @@ -73,6 +76,33 @@ public class DownloadService implements ServiceHandler { .processDownload(request, response); } + public String createDownloadURL( + final Class handlerClass, + final String downloadFileName, + final Map queryAttrs) { + + final StringBuilder url = new StringBuilder() + .append(RWT.getServiceManager() + .getServiceHandlerUrl(DownloadService.DOWNLOAD_SERVICE_NAME)) + .append(Constants.FORM_URL_ENCODED_SEPARATOR) + .append(DownloadService.HANDLER_NAME_PARAMETER) + .append(Constants.FORM_URL_ENCODED_NAME_VALUE_SEPARATOR) + .append(handlerClass.getSimpleName()) + .append(Constants.FORM_URL_ENCODED_SEPARATOR) + .append(DownloadService.DOWNLOAD_FILE_NAME) + .append(Constants.FORM_URL_ENCODED_NAME_VALUE_SEPARATOR) + .append(downloadFileName); + + queryAttrs.forEach((name, value) -> { + url.append(Constants.FORM_URL_ENCODED_SEPARATOR) + .append(name) + .append(Constants.FORM_URL_ENCODED_NAME_VALUE_SEPARATOR) + .append(Utils.encodeFormURL_UTF_8(value)); + }); + + return url.toString(); + } + public String createDownloadURL( final String modelId, final Class handlerClass, diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/SEBClientConfigDownload.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/SEBClientConfigDownload.java index 55b49146..5ff68524 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/SEBClientConfigDownload.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/SEBClientConfigDownload.java @@ -12,6 +12,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import javax.servlet.http.HttpServletRequest; + import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; @@ -35,14 +37,16 @@ public class SEBClientConfigDownload extends AbstractDownloadServiceHandler { private final RestService restService; - protected SEBClientConfigDownload( - final RestService restService) { - + protected SEBClientConfigDownload(final RestService restService) { this.restService = restService; } @Override - protected void webserviceCall(final String modelId, final String parentModelId, final OutputStream downloadOut) { + protected void webserviceCall( + final String modelId, + final String parentModelId, + final OutputStream downloadOut, + final HttpServletRequest request) { final RestCall.RestCallBuilder restCallBuilder = this.restService .getBuilder(ExportClientConfig.class) diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/SEBClientLogExport.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/SEBClientLogExport.java new file mode 100644 index 00000000..df46d6d4 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/SEBClientLogExport.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2021 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package ch.ethz.seb.sebserver.gui.service.remote.download; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Enumeration; + +import javax.servlet.http.HttpServletRequest; + +import org.apache.commons.io.IOUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import ch.ethz.seb.sebserver.gbl.profile.GuiProfile; +import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.RestService; +import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.exam.ExportSEBClientLogs; + +@Lazy +@Component +@GuiProfile +public class SEBClientLogExport extends AbstractDownloadServiceHandler { + + private static final Logger log = LoggerFactory.getLogger(SEBClientLogExport.class); + + private final RestService restService; + + protected SEBClientLogExport(final RestService restService) { + this.restService = restService; + } + + @Override + protected void webserviceCall( + final String modelId, + final String parentModelId, + final OutputStream downloadOut, + final HttpServletRequest request) { + + final MultiValueMap queryParams = new LinkedMultiValueMap<>(); + + final Enumeration paramNames = request.getParameterNames(); + while (paramNames.hasMoreElements()) { + final String param = paramNames.nextElement(); + queryParams.add(param, String.valueOf(request.getParameter(param))); + } + + final InputStream input = this.restService + .getBuilder(ExportSEBClientLogs.class) + .withQueryParams(queryParams) + .call() + .getOrThrow(); + + try { + IOUtils.copyLarge(input, downloadOut); + } catch (final IOException e) { + log.error( + "Unexpected error while streaming incoming config data from web-service to output-stream of download response: ", + e); + } finally { + try { + downloadOut.flush(); + downloadOut.close(); + } catch (final IOException e) { + log.error("Unexpected error while trying to close download output-stream"); + } + } + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/SEBExamConfigDownload.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/SEBExamConfigDownload.java index 638322b7..bca25b02 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/SEBExamConfigDownload.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/SEBExamConfigDownload.java @@ -12,6 +12,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import javax.servlet.http.HttpServletRequest; + import org.apache.commons.io.IOUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,7 +39,11 @@ public class SEBExamConfigDownload extends AbstractDownloadServiceHandler { } @Override - protected void webserviceCall(final String modelId, final String parentModelId, final OutputStream downloadOut) { + protected void webserviceCall( + final String modelId, + final String parentModelId, + final OutputStream downloadOut, + final HttpServletRequest request) { final InputStream input = this.restService.getBuilder(ExportExamConfig.class) .withURIVariable(API.PARAM_MODEL_ID, modelId) diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/SEBExamConfigPlaintextDownload.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/SEBExamConfigPlaintextDownload.java index 4c72cc58..6d844c37 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/SEBExamConfigPlaintextDownload.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/download/SEBExamConfigPlaintextDownload.java @@ -12,6 +12,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import javax.servlet.http.HttpServletRequest; + import org.apache.commons.io.IOUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -37,7 +39,11 @@ public class SEBExamConfigPlaintextDownload extends AbstractDownloadServiceHandl } @Override - protected void webserviceCall(final String modelId, final String parentModelId, final OutputStream downloadOut) { + protected void webserviceCall( + final String modelId, + final String parentModelId, + final OutputStream downloadOut, + final HttpServletRequest request) { final InputStream input = this.restService.getBuilder(ExportPlainXML.class) .withURIVariable(API.PARAM_MODEL_ID, modelId) diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/exam/ExportSEBClientLogs.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/exam/ExportSEBClientLogs.java new file mode 100644 index 00000000..34aa0a68 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/exam/ExportSEBClientLogs.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2021 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package ch.ethz.seb.sebserver.gui.service.remote.webservice.api.exam; + +import java.io.InputStream; + +import org.springframework.context.annotation.Lazy; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.type.TypeReference; + +import ch.ethz.seb.sebserver.gbl.api.API; +import ch.ethz.seb.sebserver.gbl.api.EntityType; +import ch.ethz.seb.sebserver.gbl.profile.GuiProfile; +import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.AbstractExportCall; + +@Lazy +@Component +@GuiProfile +public class ExportSEBClientLogs extends AbstractExportCall { + + public ExportSEBClientLogs() { + super(new TypeKey<>( + CallType.UNDEFINED, + EntityType.CLIENT_EVENT, + new TypeReference() { + }), + HttpMethod.GET, + MediaType.APPLICATION_FORM_URLENCODED, + API.SEB_CLIENT_EVENT_ENDPOINT + + API.SEB_CLIENT_EVENT_EXPORT_PATH_SEGMENT); + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/table/EntityTable.java b/src/main/java/ch/ethz/seb/sebserver/gui/table/EntityTable.java index 613e4b39..0d0fcb3b 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/table/EntityTable.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/table/EntityTable.java @@ -243,6 +243,14 @@ public class EntityTable { return this.name; } + public String getSortColumn() { + return this.sortColumn; + } + + public PageSortOrder getSortOrder() { + return this.sortOrder; + } + public EntityType getEntityType() { if (this.pageSupplier != null) { return this.pageSupplier.getEntityType(); diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/table/StaticListPageSupplier.java b/src/main/java/ch/ethz/seb/sebserver/gui/table/StaticListPageSupplier.java index 145426a9..b7ace63f 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/table/StaticListPageSupplier.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/table/StaticListPageSupplier.java @@ -106,8 +106,17 @@ public class StaticListPageSupplier implements PageSupplier { if (numOfPages <= 0) { return new Page<>(1, 1, this.column, this.list); } - final List subList = this.list.subList(this.pageNumber * this.pageSize, - this.pageNumber * this.pageSize + this.pageSize); + + int from = (this.pageNumber - 1) * this.pageSize; + if (from < 0) { + from = 0; + } + int to = (this.pageNumber - 1) * this.pageSize + this.pageSize; + if (to >= this.list.size()) { + to = this.list.size(); + } + + final List subList = this.list.subList(from, to); return new Page<>(numOfPages, this.pageNumber, this.column, subList); }); } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/PaginationService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/PaginationService.java index e01725aa..f35d938c 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/PaginationService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/PaginationService.java @@ -77,6 +77,30 @@ public interface PaginationService { final String tableName, final Supplier>> delegate); + /** Fetches a paged batch of objects + * + * NOTE: Paging always depends on SQL level. It depends on the collection given by the SQL select statement + * that is executed within MyBatis by using the MyBatis page service. + * Be aware that if the delegate that is given here applies an additional filter to the filtering done + * on SQL level, this will lead to paging with not fully filled pages or even to empty pages if the filter + * filters a lot of the entries given by the SQL statement away. + * So we recommend to apply as much of the filtering as possible on the SQL level and only if necessary and + * not avoidable, apply a additional filter on software-level that eventually filter one or two entities + * for a page. + * + * @param pageNumber the current page number + * @param pageSize the (full) size of the page + * @param sort the name of the sort column with a leading '-' for descending sort order + * @param tableName the name of the SQL table on which the pagination is applying to + * @param delegate a collection supplier the does the underling SQL query with specified pagination attributes + * @return Result refers to a Collection of specified type of objects or to an exception on error case */ + Result> getPageOf( + final Integer pageNumber, + final Integer pageSize, + final String sort, + final String tableName, + final Supplier>> delegate); + /** Use this to build a current Page from a given list of objects. * * @param the Type if list entities diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/PaginationServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/PaginationServiceImpl.java index 8da775b7..6083b130 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/PaginationServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/PaginationServiceImpl.java @@ -147,6 +147,29 @@ public class PaginationServiceImpl implements PaginationService { }); } + @Override + public Result> getPageOf( + final Integer pageNumber, + final Integer pageSize, + final String sort, + final String tableName, + final Supplier>> delegate) { + + return Result.tryCatch(() -> { + //final SqlTable table = SqlTable.of(tableName); + final com.github.pagehelper.Page page = + setPagination(pageNumber, pageSize, sort, tableName); + + final Collection list = delegate.get().getOrThrow(); + + return new Page<>( + page.getPages(), + page.getPageNum(), + sort, + list); + }); + } + private String verifySortColumnName(final String sort, final String columnName) { if (StringUtils.isBlank(sort)) { diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ClientConnectionDAO.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ClientConnectionDAO.java index 28caf9aa..8090c27f 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ClientConnectionDAO.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ClientConnectionDAO.java @@ -132,6 +132,8 @@ public interface ClientConnectionDAO extends key = "#connectionToken") Result removeFromProctoringRoom(Long connectionId, String connectionToken); + Result markForProctoringUpdate(Long id); + /** Deletes the given ClientConnection data. * * This evicts all entries from the CONNECTION_TOKENS_CACHE. diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ClientConnectionDAOImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ClientConnectionDAOImpl.java index 50a91818..0194f982 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ClientConnectionDAOImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ClientConnectionDAOImpl.java @@ -389,6 +389,19 @@ public class ClientConnectionDAOImpl implements ClientConnectionDAO { }); } + @Override + @Transactional + public Result markForProctoringUpdate(final Long id) { + return Result.tryCatch(() -> { + this.clientConnectionRecordMapper.updateByPrimaryKeySelective(new ClientConnectionRecord( + id, + null, null, null, null, null, + null, null, null, null, null, null, + null, + 1)); + }); + } + @Override @Transactional public Result removeFromProctoringRoom(final Long connectionId, final String connectionToken) { diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/SEBClientEventAdminService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/SEBClientEventAdminService.java new file mode 100644 index 00000000..db63ff5a --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/SEBClientEventAdminService.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2021 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.exam; + +import java.io.OutputStream; +import java.util.Collection; + +import org.springframework.scheduling.annotation.Async; + +import ch.ethz.seb.sebserver.gbl.async.AsyncServiceSpringConfig; +import ch.ethz.seb.sebserver.gbl.model.EntityProcessingReport; +import ch.ethz.seb.sebserver.gbl.model.session.ClientEvent.ExportType; +import ch.ethz.seb.sebserver.gbl.util.Result; +import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.impl.SEBServerUser; +import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap; + +public interface SEBClientEventAdminService { + + Result deleteAllClientEvents(Collection ids); + + @Async(AsyncServiceSpringConfig.EXECUTOR_BEAN_NAME) + void exportSEBClientLogs( + OutputStream output, + FilterMap filterMap, + String sort, + ExportType exportType, + boolean includeConnectionDetails, + boolean includeExamDetails, + final SEBServerUser currentUser); + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/SEBClientEventExporter.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/SEBClientEventExporter.java new file mode 100644 index 00000000..15f7fe23 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/SEBClientEventExporter.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2021 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.exam; + +import java.io.OutputStream; + +import ch.ethz.seb.sebserver.gbl.model.exam.Exam; +import ch.ethz.seb.sebserver.gbl.model.session.ClientEvent.ExportType; +import ch.ethz.seb.sebserver.webservice.datalayer.batis.model.ClientConnectionRecord; +import ch.ethz.seb.sebserver.webservice.datalayer.batis.model.ClientEventRecord; + +public interface SEBClientEventExporter { + + ExportType exportType(); + + void streamHeader( + OutputStream output, + boolean includeConnectionDetails, + boolean includeExamDetails); + + void streamData( + OutputStream output, + ClientEventRecord eventData, + ClientConnectionRecord connectionData, + Exam examData); +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/ExamAdminServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/ExamAdminServiceImpl.java index 88ec8c77..f983f507 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/ExamAdminServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/ExamAdminServiceImpl.java @@ -44,6 +44,7 @@ import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup.LmsType; import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; import ch.ethz.seb.sebserver.gbl.util.Cryptor; import ch.ethz.seb.sebserver.gbl.util.Result; +import ch.ethz.seb.sebserver.gbl.util.Utils; import ch.ethz.seb.sebserver.webservice.datalayer.batis.model.AdditionalAttributeRecord; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.AdditionalAttributesDAO; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ExamDAO; @@ -249,7 +250,7 @@ public class ExamAdminServiceImpl implements ExamAdminService { EntityType.EXAM, examId, ProctoringServiceSettings.ATTR_SERVER_URL, - proctoringServiceSettings.serverURL); + StringUtils.trim(proctoringServiceSettings.serverURL)); this.additionalAttributesDAO.saveAdditionalAttribute( EntityType.EXAM, @@ -261,13 +262,13 @@ public class ExamAdminServiceImpl implements ExamAdminService { EntityType.EXAM, examId, ProctoringServiceSettings.ATTR_APP_KEY, - proctoringServiceSettings.appKey); + StringUtils.trim(proctoringServiceSettings.appKey)); this.additionalAttributesDAO.saveAdditionalAttribute( EntityType.EXAM, examId, ProctoringServiceSettings.ATTR_APP_SECRET, - this.cryptor.encrypt(proctoringServiceSettings.appSecret) + this.cryptor.encrypt(Utils.trim(proctoringServiceSettings.appSecret)) .getOrThrow() .toString()); @@ -276,13 +277,13 @@ public class ExamAdminServiceImpl implements ExamAdminService { EntityType.EXAM, examId, ProctoringServiceSettings.ATTR_SDK_KEY, - proctoringServiceSettings.sdkKey); + StringUtils.trim(proctoringServiceSettings.sdkKey)); this.additionalAttributesDAO.saveAdditionalAttribute( EntityType.EXAM, examId, ProctoringServiceSettings.ATTR_SDK_SECRET, - this.cryptor.encrypt(proctoringServiceSettings.sdkSecret) + this.cryptor.encrypt(Utils.trim(proctoringServiceSettings.sdkSecret)) .getOrThrow() .toString()); } @@ -309,7 +310,8 @@ public class ExamAdminServiceImpl implements ExamAdminService { EntityType.EXAM, examId, ProctoringServiceSettings.ATTR_ENABLE_PROCTORING) - .map(rec -> BooleanUtils.toBoolean(rec.getValue())); + .map(rec -> BooleanUtils.toBoolean(rec.getValue())) + .onError(error -> log.error("Failed to verify proctoring enabled for exam: {}", examId, error)); if (result.hasError()) { return Result.of(false); } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/SEBClientEventAdminServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/SEBClientEventAdminServiceImpl.java new file mode 100644 index 00000000..3c395bdd --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/SEBClientEventAdminServiceImpl.java @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2021 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.exam.impl; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; + +import ch.ethz.seb.sebserver.gbl.api.APIMessage; +import ch.ethz.seb.sebserver.gbl.api.EntityType; +import ch.ethz.seb.sebserver.gbl.model.EntityKey; +import ch.ethz.seb.sebserver.gbl.model.EntityProcessingReport; +import ch.ethz.seb.sebserver.gbl.model.EntityProcessingReport.ErrorEntry; +import ch.ethz.seb.sebserver.gbl.model.Page; +import ch.ethz.seb.sebserver.gbl.model.exam.Exam; +import ch.ethz.seb.sebserver.gbl.model.session.ClientEvent.ExportType; +import ch.ethz.seb.sebserver.gbl.model.user.UserRole; +import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; +import ch.ethz.seb.sebserver.gbl.util.Result; +import ch.ethz.seb.sebserver.gbl.util.Utils; +import ch.ethz.seb.sebserver.webservice.datalayer.batis.mapper.ClientEventRecordDynamicSqlSupport; +import ch.ethz.seb.sebserver.webservice.datalayer.batis.model.ClientConnectionRecord; +import ch.ethz.seb.sebserver.webservice.datalayer.batis.model.ClientEventRecord; +import ch.ethz.seb.sebserver.webservice.servicelayer.PaginationService; +import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.impl.SEBServerUser; +import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ClientEventDAO; +import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap; +import ch.ethz.seb.sebserver.webservice.servicelayer.exam.SEBClientEventAdminService; +import ch.ethz.seb.sebserver.webservice.servicelayer.exam.SEBClientEventExporter; + +@Lazy +@Service +@WebServiceProfile +public class SEBClientEventAdminServiceImpl implements SEBClientEventAdminService { + + private static final Logger log = LoggerFactory.getLogger(SEBClientEventAdminServiceImpl.class); + + private final PaginationService paginationService; + private final ClientEventDAO clientEventDAO; + private final SEBClientEventExportTransactionHandler sebClientEventExportTransactionHandler; + private final EnumMap exporter; + + public SEBClientEventAdminServiceImpl( + final PaginationService paginationService, + final ClientEventDAO clientEventDAO, + final SEBClientEventExportTransactionHandler sebClientEventExportTransactionHandler, + final Collection exporter) { + + this.paginationService = paginationService; + this.clientEventDAO = clientEventDAO; + this.sebClientEventExportTransactionHandler = sebClientEventExportTransactionHandler; + + this.exporter = new EnumMap<>(ExportType.class); + exporter.forEach(exp -> this.exporter.putIfAbsent(exp.exportType(), exp)); + } + + @Override + public Result deleteAllClientEvents(final Collection ids) { + return Result.tryCatch(() -> { + + if (ids == null || ids.isEmpty()) { + return EntityProcessingReport.ofEmptyError(); + } + + final Set sources = ids.stream() + .map(id -> new EntityKey(id, EntityType.CLIENT_EVENT)) + .collect(Collectors.toSet()); + + final Result> delete = this.clientEventDAO.delete(sources); + if (delete.hasError()) { + return new EntityProcessingReport( + Collections.emptyList(), + Collections.emptyList(), + Arrays.asList(new ErrorEntry(null, APIMessage.ErrorMessage.UNEXPECTED.of(delete.getError())))); + } else { + return new EntityProcessingReport( + sources, + delete.get(), + Collections.emptyList()); + } + }); + } + + @Override + public void exportSEBClientLogs( + final OutputStream output, + final FilterMap filterMap, + final String sort, + final ExportType exportType, + final boolean includeConnectionDetails, + final boolean includeExamDetails, + final SEBServerUser currentUser) { + + try { + + new exportRunner( + this.exporter.get(exportType), + includeConnectionDetails, + includeExamDetails, + new Pager(filterMap, sort), + output) + .run(currentUser); + } catch (final Exception e) { + log.error("Unexpected error during export SEB logs: ", e); + } finally { + try { + output.flush(); + } catch (final IOException e) { + e.printStackTrace(); + } + try { + output.close(); + } catch (final IOException e) { + e.printStackTrace(); + } + } + + } + + private class exportRunner { + + private final SEBClientEventExporter exporter; + private final boolean includeConnectionDetails; + private final boolean includeExamDetails; + private final Iterator> pager; + private final OutputStream output; + + private final Map examCache; + private final Map connectionCache; + + public exportRunner( + final SEBClientEventExporter exporter, + final boolean includeConnectionDetails, + final boolean includeExamDetails, + final Iterator> pager, + final OutputStream output) { + + this.exporter = exporter; + this.includeConnectionDetails = includeConnectionDetails; + this.includeExamDetails = includeExamDetails; + this.pager = pager; + this.output = output; + + this.connectionCache = new HashMap<>(); + this.examCache = new HashMap<>(); + } + + public void run(final SEBServerUser currentUser) { + + final EnumSet userRoles = currentUser.getUserRoles(); + final boolean isSupporterOnly = userRoles.size() == 1 && userRoles.contains(UserRole.EXAM_SUPPORTER); + + // first stream header line + this.exporter.streamHeader(this.output, this.includeConnectionDetails, this.includeExamDetails); + + // then batch with the pager and stream line per line + while (this.pager.hasNext()) { + this.pager.next().forEach(rec -> { + + final Exam exam = getExam(rec.getClientConnectionId()); + + if (!isSupporterOnly || exam.isOwner(currentUser.uuid())) { + this.exporter.streamData( + this.output, + rec, + this.includeConnectionDetails ? getConnection(rec.getClientConnectionId()) : null, + this.includeExamDetails ? getExam(rec.getClientConnectionId()) : null); + } + }); + } + } + + private ClientConnectionRecord getConnection(final Long connectionId) { + if (!this.connectionCache.containsKey(connectionId)) { + SEBClientEventAdminServiceImpl.this.sebClientEventExportTransactionHandler + .clientConnectionById(connectionId) + .onSuccess(rec -> this.connectionCache.put(rec.getId(), rec)) + .onError(error -> log.error("Failed to get ClientConnectionRecord for id: {}", + connectionId, + error)); + } + + return this.connectionCache.get(connectionId); + } + + private Exam getExam(final Long connectionId) { + final ClientConnectionRecord connection = getConnection(connectionId); + final Long examId = connection.getExamId(); + if (!this.examCache.containsKey(examId)) { + SEBClientEventAdminServiceImpl.this.sebClientEventExportTransactionHandler + .examById(examId) + .onSuccess(e -> this.examCache.put(examId, e)) + .onError(error -> log.error("Failed to get Exam for id: {}", + examId, + error)); + } + + return this.examCache.get(examId); + } + } + + private class Pager implements Iterator> { + + private final FilterMap filterMap; + private final String sort; + + private int pageNumber = 1; + private final int pageSize = 100; + + private Collection nextRecords; + + public Pager( + final FilterMap filterMap, + final String sort) { + + this.filterMap = filterMap; + this.sort = sort; + + fetchNext(); + } + + @Override + public boolean hasNext() { + return this.nextRecords != null && !this.nextRecords.isEmpty(); + } + + @Override + public Collection next() { + final Collection result = this.nextRecords; + fetchNext(); + return result; + } + + private void fetchNext() { + try { + + final Page nextPage = SEBClientEventAdminServiceImpl.this.paginationService + .getPageOf( + this.pageNumber, + this.pageSize, + this.sort, + ClientEventRecordDynamicSqlSupport.clientEventRecord.name(), + () -> SEBClientEventAdminServiceImpl.this.sebClientEventExportTransactionHandler + .allMatching(this.filterMap, Utils.truePredicate())) + .getOrThrow(); + + if (nextPage.getPageNumber() == this.pageNumber) { + this.nextRecords = nextPage.content; + this.pageNumber++; + } else { + this.nextRecords = null; + } + + } catch (final Exception e) { + log.error("Failed to fetch next batch: ", e); + this.nextRecords = null; + } + } + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/SEBClientEventCSVExporter.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/SEBClientEventCSVExporter.java new file mode 100644 index 00000000..38f031a6 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/SEBClientEventCSVExporter.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2021 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.exam.impl; + +import java.io.IOException; +import java.io.OutputStream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; + +import ch.ethz.seb.sebserver.gbl.Constants; +import ch.ethz.seb.sebserver.gbl.model.exam.Exam; +import ch.ethz.seb.sebserver.gbl.model.session.ClientEvent.EventType; +import ch.ethz.seb.sebserver.gbl.model.session.ClientEvent.ExportType; +import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; +import ch.ethz.seb.sebserver.gbl.util.Utils; +import ch.ethz.seb.sebserver.webservice.datalayer.batis.model.ClientConnectionRecord; +import ch.ethz.seb.sebserver.webservice.datalayer.batis.model.ClientEventRecord; +import ch.ethz.seb.sebserver.webservice.servicelayer.exam.SEBClientEventExporter; + +@Lazy +@Component +@WebServiceProfile +public class SEBClientEventCSVExporter implements SEBClientEventExporter { + + private static final Logger log = LoggerFactory.getLogger(SEBClientEventCSVExporter.class); + + @Override + public ExportType exportType() { + return ExportType.CSV; + } + + @Override + public void streamHeader( + final OutputStream output, + final boolean includeConnectionDetails, + final boolean includeExamDetails) { + + final StringBuilder builder = new StringBuilder(); + builder.append("Event Type,Message,Value,Client Time (UTC),Server Time (UTC)"); + + if (includeConnectionDetails) { + builder.append(",User Session-ID,Client Machine,Connection Status,Connection Token"); + } + + if (includeExamDetails) { + builder.append("Exam Name,Exam Description,Exam Type,Start Time (LMS),End Time (LMS)"); + } + + builder.append(Constants.CARRIAGE_RETURN); + + try { + output.write(Utils.toByteArray(builder)); + } catch (final IOException e) { + log.error("Failed to stream header: ", e); + } finally { + try { + output.flush(); + } catch (final IOException e) { + e.printStackTrace(); + } + } + } + + @Override + public void streamData( + final OutputStream output, + final ClientEventRecord eventData, + final ClientConnectionRecord connectionData, + final Exam examData) { + + final StringBuilder builder = new StringBuilder(); + final EventType type = EventType.byId(eventData.getType()); + + builder.append(type.name()); + builder.append(Constants.COMMA); + builder.append(Utils.toCSVString(eventData.getText())); + builder.append(Constants.COMMA); + builder.append(eventData.getNumericValue() != null ? eventData.getNumericValue() : ""); + builder.append(Constants.COMMA); + builder.append(Utils.toDateTimeUTC(eventData.getClientTime())); + builder.append(Constants.COMMA); + builder.append(Utils.toDateTimeUTC(eventData.getServerTime())); + + if (connectionData != null) { + builder.append(Constants.COMMA); + builder.append(Utils.toCSVString(connectionData.getExamUserSessionId())); + builder.append(Constants.COMMA); + builder.append(Utils.toCSVString(connectionData.getClientAddress())); + builder.append(Constants.COMMA); + builder.append(connectionData.getStatus()); + builder.append(Constants.COMMA); + builder.append(connectionData.getConnectionToken()); + } + + if (examData != null) { + builder.append(Constants.COMMA); + builder.append(Utils.toCSVString(examData.getName())); + builder.append(Constants.COMMA); + builder.append(Utils.toCSVString(examData.getDescription())); + builder.append(Constants.COMMA); + builder.append(examData.getType().name()); + builder.append(Constants.COMMA); + builder.append(examData.getStartTime()); + builder.append(Constants.COMMA); + builder.append(examData.getEndTime()); + } + + builder.append(Constants.CARRIAGE_RETURN); + + try { + output.write(Utils.toByteArray(builder)); + } catch (final IOException e) { + log.error("Failed to stream header: ", e); + } finally { + try { + output.flush(); + } catch (final IOException e) { + e.printStackTrace(); + } + } + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/SEBClientEventExportTransactionHandler.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/SEBClientEventExportTransactionHandler.java new file mode 100644 index 00000000..6e24f501 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/exam/impl/SEBClientEventExportTransactionHandler.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2021 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.exam.impl; + +import static org.mybatis.dynamic.sql.SqlBuilder.equalTo; +import static org.mybatis.dynamic.sql.SqlBuilder.isEqualToWhenPresent; + +import java.util.Collection; +import java.util.function.Predicate; + +import org.mybatis.dynamic.sql.SqlBuilder; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import ch.ethz.seb.sebserver.gbl.model.exam.Exam; +import ch.ethz.seb.sebserver.gbl.model.session.ClientConnection; +import ch.ethz.seb.sebserver.gbl.model.session.ClientEvent; +import ch.ethz.seb.sebserver.gbl.model.session.ClientEvent.EventType; +import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; +import ch.ethz.seb.sebserver.gbl.util.Result; +import ch.ethz.seb.sebserver.webservice.datalayer.batis.mapper.ClientConnectionRecordDynamicSqlSupport; +import ch.ethz.seb.sebserver.webservice.datalayer.batis.mapper.ClientConnectionRecordMapper; +import ch.ethz.seb.sebserver.webservice.datalayer.batis.mapper.ClientEventRecordDynamicSqlSupport; +import ch.ethz.seb.sebserver.webservice.datalayer.batis.mapper.ClientEventRecordMapper; +import ch.ethz.seb.sebserver.webservice.datalayer.batis.model.ClientConnectionRecord; +import ch.ethz.seb.sebserver.webservice.datalayer.batis.model.ClientEventRecord; +import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ExamDAO; +import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap; + +@Lazy +@Component +@WebServiceProfile +public class SEBClientEventExportTransactionHandler { + + private final ClientEventRecordMapper clientEventRecordMapper; + private final ClientConnectionRecordMapper clientConnectionRecordMapper; + private final ExamDAO examDAO; + + public SEBClientEventExportTransactionHandler( + final ClientEventRecordMapper clientEventRecordMapper, + final ClientConnectionRecordMapper clientConnectionRecordMapper, + final ExamDAO examDAO) { + + this.clientEventRecordMapper = clientEventRecordMapper; + this.clientConnectionRecordMapper = clientConnectionRecordMapper; + this.examDAO = examDAO; + } + + @Transactional(readOnly = true) + public Result> allMatching( + final FilterMap filterMap, + final Predicate predicate) { + + return Result.tryCatch(() -> this.clientEventRecordMapper + .selectByExample() + .leftJoin(ClientConnectionRecordDynamicSqlSupport.clientConnectionRecord) + .on( + ClientConnectionRecordDynamicSqlSupport.id, + equalTo(ClientEventRecordDynamicSqlSupport.clientConnectionId)) + .where( + ClientConnectionRecordDynamicSqlSupport.institutionId, + isEqualToWhenPresent(filterMap.getInstitutionId())) + .and( + ClientConnectionRecordDynamicSqlSupport.examId, + isEqualToWhenPresent(filterMap.getClientEventExamId())) + .and( + ClientConnectionRecordDynamicSqlSupport.examUserSessionId, + SqlBuilder.isLikeWhenPresent(filterMap.getSQLWildcard(ClientConnection.FILTER_ATTR_SESSION_ID))) + .and( + ClientEventRecordDynamicSqlSupport.clientConnectionId, + isEqualToWhenPresent(filterMap.getClientEventConnectionId())) + .and( + ClientEventRecordDynamicSqlSupport.type, + isEqualToWhenPresent(filterMap.getClientEventTypeId())) + .and( + ClientEventRecordDynamicSqlSupport.type, + SqlBuilder.isNotEqualTo(EventType.LAST_PING.id)) + .and( + ClientEventRecordDynamicSqlSupport.clientTime, + SqlBuilder.isGreaterThanOrEqualToWhenPresent(filterMap.getClientEventClientTimeFrom())) + .and( + ClientEventRecordDynamicSqlSupport.clientTime, + SqlBuilder.isLessThanOrEqualToWhenPresent(filterMap.getClientEventClientTimeTo())) + .and( + ClientEventRecordDynamicSqlSupport.serverTime, + SqlBuilder.isGreaterThanOrEqualToWhenPresent(filterMap.getClientEventServerTimeFrom())) + .and( + ClientEventRecordDynamicSqlSupport.serverTime, + SqlBuilder.isLessThanOrEqualToWhenPresent(filterMap.getClientEventServerTimeTo())) + .and( + ClientEventRecordDynamicSqlSupport.text, + SqlBuilder.isLikeWhenPresent(filterMap.getClientEventText())) + .build() + .execute()); + } + + @Transactional(readOnly = true) + public Result clientConnectionById(final Long id) { + return Result.tryCatch(() -> this.clientConnectionRecordMapper.selectByPrimaryKey(id)); + } + + public Result examById(final Long id) { + return this.examDAO.byPK(id); + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/SEBRestrictionServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/SEBRestrictionServiceImpl.java index dfc47920..d703945f 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/SEBRestrictionServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/SEBRestrictionServiceImpl.java @@ -202,7 +202,8 @@ public class SEBRestrictionServiceImpl implements SEBRestrictionService { .map(sebRestrictionData -> { if (log.isDebugEnabled()) { - log.debug("Applying SEB Client restriction on LMS with: {}", sebRestrictionData); + log.debug(" *** SEB Restriction *** Applying SEB Client restriction on LMS with: {}", + sebRestrictionData); } return this.lmsAPIService @@ -221,7 +222,7 @@ public class SEBRestrictionServiceImpl implements SEBRestrictionService { public Result releaseSEBClientRestriction(final Exam exam) { if (log.isDebugEnabled()) { - log.debug("Release SEB Client restrictions for exam: {}", exam); + log.debug(" *** SEB Restriction *** Release SEB Client restrictions from LMS for exam: {}", exam); } return this.lmsAPIService diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/mockup/MockupLmsAPITemplate.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/mockup/MockupLmsAPITemplate.java index cad64113..35e67a9f 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/mockup/MockupLmsAPITemplate.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/mockup/MockupLmsAPITemplate.java @@ -236,10 +236,6 @@ public class MockupLmsAPITemplate implements LmsAPITemplate { this.webserviceInfo.getHttpScheme() + "://" + externalAddressAlias + "/api/"; - if (log.isTraceEnabled()) { - log.trace("Use external address for course access: {}", _externalStartURI); - } - return new QuizData( quizData.id, quizData.institutionId, quizData.lmsSetupId, quizData.lmsType, quizData.name, quizData.description, quizData.startTime, diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/SEBClientConnectionServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/SEBClientConnectionServiceImpl.java index 679da882..0e476e64 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/SEBClientConnectionServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/SEBClientConnectionServiceImpl.java @@ -267,7 +267,8 @@ public class SEBClientConnectionServiceImpl implements SEBClientConnectionServic // connection integrity check if (clientConnection.status == ConnectionStatus.ACTIVE) { - if (clientConnection.clientAddress != null && clientConnection.clientAddress.equals(clientAddress)) { + if (clientConnection.clientAddress != null && + (StringUtils.isBlank(clientAddress) || clientConnection.clientAddress.equals(clientAddress))) { // It seems that this is the same SEB that tries to establish the connection once again. // Just log this and return already established connection if (log.isDebugEnabled()) { diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/SEBClientInstructionServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/SEBClientInstructionServiceImpl.java index 2aef00c8..238f497a 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/SEBClientInstructionServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/SEBClientInstructionServiceImpl.java @@ -216,7 +216,7 @@ public class SEBClientInstructionServiceImpl implements SEBClientInstructionServ .toString(); if (log.isDebugEnabled()) { - log.debug("Send SEB client instruction: {} to: {} ", connectionToken, instructionJSON); + log.debug("Send SEB client instruction: {} to: {} ", instructionJSON, connectionToken); } return instructionJSON; diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/indicator/AbstractPingIndicator.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/indicator/AbstractPingIndicator.java index 553d972d..a67a7546 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/indicator/AbstractPingIndicator.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/indicator/AbstractPingIndicator.java @@ -77,7 +77,9 @@ public abstract class AbstractPingIndicator extends AbstractClientIndicator { // Update last ping time on persistent storage final long millisecondsNow = DateTimeUtils.currentTimeMillis(); if (millisecondsNow - this.lastUpdate > INTERVAL_FOR_PERSISTENT_UPDATE) { - this.distributedPingCache.updatePing(this.pingRecord, millisecondsNow); + synchronized (this) { + this.distributedPingCache.updatePing(this.pingRecord, millisecondsNow); + } } } } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/indicator/DistributedPingCache.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/indicator/DistributedPingCache.java index e35bbdf5..5c027577 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/indicator/DistributedPingCache.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/indicator/DistributedPingCache.java @@ -21,9 +21,11 @@ import org.joda.time.DateTimeUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.scheduling.TaskScheduler; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import ch.ethz.seb.sebserver.gbl.model.session.ClientEvent.EventType; @@ -51,13 +53,14 @@ public class DistributedPingCache implements DisposableBean { final ClientEventLastPingMapper clientEventLastPingMapper, final ClientEventRecordMapper clientEventRecordMapper, final WebserviceInfo webserviceInfo, - final TaskScheduler taskScheduler) { + final TaskScheduler taskScheduler, + @Value("${sebserver.webservice.distributed.pingUpdate:3000}") final long pingUpdate) { this.clientEventLastPingMapper = clientEventLastPingMapper; this.clientEventRecordMapper = clientEventRecordMapper; if (webserviceInfo.isDistributed()) { try { - this.taskRef = taskScheduler.scheduleAtFixedRate(this::updateCache, 1000); + this.taskRef = taskScheduler.scheduleAtFixedRate(this::updateCache, pingUpdate); } catch (final Exception e) { log.error("Failed to initialize distributed ping cache update task"); this.taskRef = null; @@ -126,7 +129,6 @@ public class DistributedPingCache implements DisposableBean { } } - @Transactional public void updatePing(final Long pingRecordId, final Long pingTime) { try { @@ -155,6 +157,8 @@ public class DistributedPingCache implements DisposableBean { } catch (final Exception e) { log.error("Failed to delete ping for connection -> {}", connectionId, e); + } finally { + this.pingCache.remove(connectionId); } } @@ -180,7 +184,7 @@ public class DistributedPingCache implements DisposableBean { } } - @Transactional + @Transactional(readOnly = true, isolation = Isolation.READ_UNCOMMITTED) public void updateCache() { if (this.pingCache.isEmpty()) { diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ExamProctoringRoomServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ExamProctoringRoomServiceImpl.java index 90d2c1d0..57c798fc 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ExamProctoringRoomServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ExamProctoringRoomServiceImpl.java @@ -282,22 +282,29 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService private void assignToCollectingRoom(final ClientConnectionRecord cc) { try { - final RemoteProctoringRoom proctoringRoom = getProctoringRoom( - cc.getExamId(), - cc.getConnectionToken()); + if (cc.getRemoteProctoringRoomId() == null) { - this.clientConnectionDAO - .assignToProctoringRoom( - cc.getId(), - cc.getConnectionToken(), - proctoringRoom.id) + final RemoteProctoringRoom proctoringRoom = getProctoringRoom( + cc.getExamId(), + cc.getConnectionToken()); + + if (log.isDebugEnabled()) { + log.debug("Assigning new SEB client to proctoring room: {}, connection: {}", + proctoringRoom.id, + cc); + } + + this.clientConnectionDAO + .assignToProctoringRoom( + cc.getId(), + cc.getConnectionToken(), + proctoringRoom.id) + .getOrThrow(); + } + + applyProcotringInstruction(cc) .getOrThrow(); - applyProcotringInstruction( - cc.getExamId(), - cc.getConnectionToken()) - .getOrThrow(); - } catch (final Exception e) { log.error("Failed to assign connection to collecting room: {}", cc, e); } @@ -598,11 +605,11 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService }); } - private Result applyProcotringInstruction( - final Long examId, - final String connectionToken) { + private Result applyProcotringInstruction(final ClientConnectionRecord cc) { return Result.tryCatch(() -> { + final Long examId = cc.getExamId(); + final String connectionToken = cc.getConnectionToken(); final ProctoringServiceSettings settings = this.examAdminService .getProctoringServiceSettings(examId) .getOrThrow(); @@ -624,17 +631,32 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService townhallRoom.subject) .getOrThrow(); - sendJoinInstruction( - examId, - connectionToken, - roomConnection, - examProctoringService); + try { + sendJoinInstruction( + examId, + connectionToken, + roomConnection, + examProctoringService); + } catch (final Exception e) { + log.error("Failed to send join for town-hall room assignment to connection: {}", cc); + this.clientConnectionDAO + .markForProctoringUpdate(cc.getId()) + .onError(error -> log.error("Failed to mark connection for proctoring update: ", error)); + } } else { + try { - sendJoinCollectingRoomInstructions( - settings, - Arrays.asList(connectionToken), - examProctoringService); + sendJoinCollectingRoomInstruction( + settings, + examProctoringService, + connectionToken); + + } catch (final Exception e) { + log.error("Failed to send join for collecting room assignment to connection: {}", cc); + this.clientConnectionDAO + .markForProctoringUpdate(cc.getId()) + .onError(error -> log.error("Failed to mark connection for proctoring update: ", error)); + } } }); @@ -653,23 +675,30 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService clientConnectionTokens .stream() .forEach(connectionToken -> { - final ProctoringRoomConnection proctoringConnection = examProctoringService - .getClientRoomConnection( - proctoringSettings, + try { + final ProctoringRoomConnection proctoringConnection = examProctoringService + .getClientRoomConnection( + proctoringSettings, + connectionToken, + roomName, + (StringUtils.isNotBlank(subject)) ? subject : roomName) + .onError(error -> log.error( + "Failed to get client room connection data for {} cause: {}", + connectionToken, + error.getMessage())) + .get(); + if (proctoringConnection != null) { + sendJoinInstruction( + proctoringSettings.examId, connectionToken, - roomName, - (StringUtils.isNotBlank(subject)) ? subject : roomName) - .onError(error -> log.error( - "Failed to get client room connection data for {} cause: {}", - connectionToken, - error.getMessage())) - .get(); - if (proctoringConnection != null) { - sendJoinInstruction( - proctoringSettings.examId, - connectionToken, - proctoringConnection, - examProctoringService); + proctoringConnection, + examProctoringService); + } + } catch (final Exception e) { + log.error("Failed to send join to break-out room: {} connection: {}", + roomName, + roomName, + e); } }); @@ -688,10 +717,18 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService clientConnectionTokens .stream() - .forEach(connectionToken -> sendJoinCollectingRoomInstruction( - proctoringSettings, - examProctoringService, - connectionToken)); + .forEach(connectionToken -> { + try { + sendJoinCollectingRoomInstruction( + proctoringSettings, + examProctoringService, + connectionToken); + } catch (final Exception e) { + log.error( + "Failed to send proctoring room join instruction to single client. Skip and proceed with other clients. {}", + e.getMessage()); + } + }); } private void sendJoinCollectingRoomInstruction( @@ -721,8 +758,10 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService clientConnection.clientConnection.connectionToken, proctoringConnection, examProctoringService); + } catch (final Exception e) { log.error("Failed to send proctoring room join instruction to client: {}", connectionToken, e); + throw e; } } @@ -732,6 +771,12 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService final ProctoringRoomConnection proctoringConnection, final ExamProctoringService examProctoringService) { + if (log.isDebugEnabled()) { + log.debug("Send proctoring join instruction to connection: {}, room: {}", + connectionToken, + proctoringConnection.roomName); + } + final Map attributes = examProctoringService .createJoinInstructionAttributes(proctoringConnection); @@ -742,7 +787,8 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService attributes, connectionToken, true) - .onError(error -> log.error("Failed to send join instruction: {}", connectionToken, error)); + .onError(error -> log.error("Failed to send join instruction: {}", connectionToken, error)) + .getOrThrow(); } } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ZoomProctoringService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ZoomProctoringService.java index 0118f4b9..eed51752 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ZoomProctoringService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ZoomProctoringService.java @@ -623,6 +623,10 @@ public class ZoomProctoringService implements ExamProctoringService { credentials.clientIdAsString(), expTime); + if (log.isTraceEnabled()) { + log.trace("Zoom API Token payload: {}", jwtPayload); + } + final String jwtPayloadPart = urlEncoder .encodeToString(jwtPayload.getBytes(StandardCharsets.UTF_8)); @@ -675,6 +679,10 @@ public class ZoomProctoringService implements ExamProctoringService { expTime, expTime); + if (log.isTraceEnabled()) { + log.trace("Zoom SDK Token payload: {}", jwtPayload); + } + final String jwtPayloadPart = urlEncoder .encodeToString(jwtPayload.getBytes(StandardCharsets.UTF_8)); @@ -724,6 +732,10 @@ public class ZoomProctoringService implements ExamProctoringService { final String tmpString = String.format("%s.%s.%s.%d.%s", apiKey, meetingId, ts, status, hashBase64Str); final String encodedString = Base64.getEncoder().encodeToString(tmpString.getBytes()); + if (log.isTraceEnabled()) { + log.trace("Zoom Meeting signature payload: {}", tmpString); + } + return encodedString.replaceAll("\\=+$", ""); } catch (final Exception e) { @@ -732,12 +744,16 @@ public class ZoomProctoringService implements ExamProctoringService { } private long forExam(final ProctoringServiceSettings examProctoring) { - if (examProctoring.examId == null) { - throw new IllegalStateException("Missing exam identifier from ExamProctoring data"); - } - long expTime = Utils.toSeconds(System.currentTimeMillis() + Constants.DAY_IN_MILLIS); - if (this.examSessionService.isExamRunning(examProctoring.examId)) { + // TODO + // NOTE: following is the original code that includes the exam end time but seems to make trouble for OLAT + final long nowInSeconds = Utils.getSecondsNow(); + final long nowPlus30MinInSeconds = nowInSeconds + Utils.toSeconds(30 * Constants.MINUTE_IN_MILLIS); + final long nowPlusOneDayInSeconds = nowInSeconds + Utils.toSeconds(Constants.DAY_IN_MILLIS); + final long nowPlusTwoDayInSeconds = nowInSeconds + Utils.toSeconds(2 * Constants.DAY_IN_MILLIS); + + long expTime = nowPlusOneDayInSeconds; + if (examProctoring.examId == null && this.examSessionService.isExamRunning(examProctoring.examId)) { final Exam exam = this.examSessionService.getRunningExam(examProctoring.examId) .getOrThrow(); if (exam.endTime != null) { @@ -746,10 +762,16 @@ public class ZoomProctoringService implements ExamProctoringService { } // refer to https://marketplace.zoom.us/docs/sdk/native-sdks/auth // "exp": 0, //JWT expiration date (Min:1800 seconds greater than iat value, Max: 48 hours greater than iat value) in epoch format. - if (expTime - Utils.getSecondsNow() > Utils.toSeconds(2 * Constants.DAY_IN_MILLIS)) { - expTime = Utils.toSeconds(System.currentTimeMillis() + Constants.DAY_IN_MILLIS); + if (expTime > nowPlusTwoDayInSeconds) { + expTime = nowPlusTwoDayInSeconds - 10; // Do not set to max because it is not well defined if max is included or not + } else if (expTime < nowPlus30MinInSeconds) { + expTime = nowPlusOneDayInSeconds; } - return expTime; + + log.debug("**** SDK Token exp time with exam-end-time inclusion would be: {}", expTime); + + // NOTE: Set this to the maximum according to https://marketplace.zoom.us/docs/sdk/native-sdks/auth + return nowPlusTwoDayInSeconds - 10; // Do not set to max because it is not well defined if max is included or not; } private final static class ZoomRestTemplate { diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ClientEventController.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ClientEventController.java index 349c42eb..79dc124c 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ClientEventController.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ClientEventController.java @@ -8,17 +8,20 @@ package ch.ethz.seb.sebserver.webservice.weblayer.api; -import java.util.Arrays; +import java.io.IOException; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; import java.util.Collection; -import java.util.Collections; import java.util.EnumSet; import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; +import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.apache.commons.io.IOUtils; import org.mybatis.dynamic.sql.SqlTable; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.RequestMapping; @@ -28,16 +31,14 @@ import org.springframework.web.bind.annotation.RestController; import ch.ethz.seb.sebserver.gbl.api.API; import ch.ethz.seb.sebserver.gbl.api.API.BulkActionType; -import ch.ethz.seb.sebserver.gbl.api.APIMessage; import ch.ethz.seb.sebserver.gbl.api.EntityType; import ch.ethz.seb.sebserver.gbl.api.authorization.PrivilegeType; import ch.ethz.seb.sebserver.gbl.model.EntityDependency; -import ch.ethz.seb.sebserver.gbl.model.EntityKey; import ch.ethz.seb.sebserver.gbl.model.EntityProcessingReport; -import ch.ethz.seb.sebserver.gbl.model.EntityProcessingReport.ErrorEntry; import ch.ethz.seb.sebserver.gbl.model.GrantEntity; import ch.ethz.seb.sebserver.gbl.model.Page; import ch.ethz.seb.sebserver.gbl.model.session.ClientEvent; +import ch.ethz.seb.sebserver.gbl.model.session.ClientEvent.ExportType; import ch.ethz.seb.sebserver.gbl.model.session.ExtendedClientEvent; import ch.ethz.seb.sebserver.gbl.model.user.UserRole; import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; @@ -53,6 +54,7 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ClientEventDAO; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ExamDAO; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.UserActivityLogDAO; +import ch.ethz.seb.sebserver.webservice.servicelayer.exam.SEBClientEventAdminService; import ch.ethz.seb.sebserver.webservice.servicelayer.validation.BeanValidationService; @WebServiceProfile @@ -62,6 +64,7 @@ public class ClientEventController extends ReadonlyEntityController sources = ids.stream() - .map(id -> new EntityKey(id, EntityType.CLIENT_EVENT)) - .collect(Collectors.toSet()); + @RequestMapping( + path = API.SEB_CLIENT_EVENT_EXPORT_PATH_SEGMENT, + method = RequestMethod.GET, + produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + public void exportEvents( + @RequestParam( + name = API.PARAM_INSTITUTION_ID, + required = true, + defaultValue = UserService.USERS_INSTITUTION_AS_DEFAULT) final Long institutionId, + @RequestParam(name = API.SEB_CLIENT_EVENT_EXPORT_TYPE, required = true) final ExportType type, + @RequestParam( + name = API.SEB_CLIENT_EVENT_EXPORT_INCLUDE_CONNECTIONS, + required = false, + defaultValue = "true") final boolean includeConnectionDetails, + @RequestParam( + name = API.SEB_CLIENT_EVENT_EXPORT_INCLUDE_EXAMS, + required = false, + defaultValue = "false") final boolean includeExamDetails, + @RequestParam(name = Page.ATTR_SORT, required = false) final String sort, + @RequestParam final MultiValueMap allRequestParams, + final HttpServletRequest request, + final HttpServletResponse response) throws IOException { - final Result> delete = this.clientEventDAO.delete(sources); + // at least current user must have base read access for specified entity type within its own institution + checkReadPrivilege(institutionId); - if (delete.hasError()) { - return new EntityProcessingReport( - Collections.emptyList(), - Collections.emptyList(), - Arrays.asList(new ErrorEntry(null, APIMessage.ErrorMessage.UNEXPECTED.of(delete.getError())))); - } else { - return new EntityProcessingReport( - sources, - delete.get(), - Collections.emptyList()); + final FilterMap filterMap = new FilterMap(allRequestParams, request.getQueryString()); + populateFilterMap(filterMap, institutionId, sort); + + final ServletOutputStream outputStream = response.getOutputStream(); + PipedOutputStream pout; + PipedInputStream pin; + try { + pout = new PipedOutputStream(); + pin = new PipedInputStream(pout); + + final SEBServerUser currentUser = this.authorization + .getUserService() + .getCurrentUser(); + + this.sebClientEventAdminService.exportSEBClientLogs( + pout, + filterMap, + sort, + type, + includeConnectionDetails, + includeExamDetails, + currentUser); + + IOUtils.copyLarge(pin, outputStream); + + response.setStatus(HttpStatus.OK.value()); + + outputStream.flush(); + + } finally { + outputStream.flush(); + outputStream.close(); } } @@ -175,12 +223,14 @@ public class ClientEventController extends ReadonlyEntityController checkReadAccess(final ClientEvent entity) { + final EnumSet userRoles = this.authorization + .getUserService() + .getCurrentUser() + .getUserRoles(); + final boolean isSupporterOnly = userRoles.size() == 1 && userRoles.contains(UserRole.EXAM_SUPPORTER); + return Result.tryCatch(() -> { - final EnumSet userRoles = this.authorization - .getUserService() - .getCurrentUser() - .getUserRoles(); - final boolean isSupporterOnly = userRoles.size() == 1 && userRoles.contains(UserRole.EXAM_SUPPORTER); + if (isSupporterOnly) { // check owner grant be getting exam return super.checkReadAccess(entity) diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamAPI_V1_Controller.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamAPI_V1_Controller.java index 4120f6b1..d3c7d614 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamAPI_V1_Controller.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamAPI_V1_Controller.java @@ -131,7 +131,8 @@ public class ExamAPI_V1_Controller { .map(this::createRunningExamInfo) .collect(Collectors.toList()); } else { - final Exam exam = this.examSessionService.getExamDAO().byPK(examId) + final Exam exam = this.examSessionService.getExamDAO() + .byPK(examId) .getOrThrow(); result = Arrays.asList(createRunningExamInfo(exam)); diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamProctoringController.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamProctoringController.java index 1be45e90..10a0efb1 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamProctoringController.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/ExamProctoringController.java @@ -327,7 +327,7 @@ public class ExamProctoringController { this.authorizationService.checkRead(this.examSessionService .getExamDAO() - .byPK(examId) + .examGrantEntityByPK(examId) .getOrThrow()); } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/IndicatorController.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/IndicatorController.java index 1548f22c..c5e4d833 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/IndicatorController.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/IndicatorController.java @@ -72,11 +72,10 @@ public class IndicatorController extends EntityController @Override protected Indicator createNew(final POSTMapper postParams) { final Long examId = postParams.getLong(Domain.INDICATOR.ATTR_EXAM_ID); - - return this.examDao - .byPK(examId) - .map(exam -> new Indicator(exam, postParams)) - .getOrThrow(); + if (examId == null) { + throw new RuntimeException("Missing exam model id from request parameter map!"); + } + return new Indicator(examId, postParams); } @Override @@ -120,7 +119,8 @@ public class IndicatorController extends EntityController return null; } - return this.examDao.byPK(entity.examId) + return this.examDao + .examGrantEntityByPK(entity.examId) .getOrThrow(); } diff --git a/src/main/resources/config/application-dev-ws.properties b/src/main/resources/config/application-dev-ws.properties index 277c15de..374fe1ed 100644 --- a/src/main/resources/config/application-dev-ws.properties +++ b/src/main/resources/config/application-dev-ws.properties @@ -13,6 +13,7 @@ spring.datasource.hikari.initializationFailTimeout=30000 spring.datasource.hikari.connectionTimeout=30000 spring.datasource.hikari.idleTimeout=600000 spring.datasource.hikari.maxLifetime=1800000 +spring.datasource.hikari.maximumPoolSize=500 sebserver.http.client.connect-timeout=15000 sebserver.http.client.connection-request-timeout=10000 @@ -22,7 +23,7 @@ sebserver.webservice.clean-db-on-startup=false # webservice configuration sebserver.init.adminaccount.gen-on-init=false -sebserver.webservice.distributed=true +sebserver.webservice.distributed=false sebserver.webservice.master.delay.threshold=10000 sebserver.webservice.http.external.scheme=http sebserver.webservice.http.external.servername=localhost diff --git a/src/main/resources/config/application-dev.properties b/src/main/resources/config/application-dev.properties index b11db757..ae66eec3 100644 --- a/src/main/resources/config/application-dev.properties +++ b/src/main/resources/config/application-dev.properties @@ -10,8 +10,9 @@ server.tomcat.uri-encoding=UTF-8 logging.level.ch=INFO logging.level.ch.ethz.seb.sebserver.webservice.datalayer=INFO logging.level.org.springframework.cache=INFO -logging.level.ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl=DEBUG +logging.level.ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl=TRACE logging.level.ch.ethz.seb.sebserver.webservice.servicelayer.session=DEBUG +logging.level.ch.ethz.seb.sebserver.webservice.servicelayer.session.impl.proctoring=TRACE #logging.level.ch.ethz.seb.sebserver.webservice.weblayer.api.ExamAPI_V1_Controller=TRACE sebserver.http.client.connect-timeout=150000 diff --git a/src/main/resources/config/application-gui.properties b/src/main/resources/config/application-gui.properties index f7a8edfa..1b89c317 100644 --- a/src/main/resources/config/application-gui.properties +++ b/src/main/resources/config/application-gui.properties @@ -25,7 +25,7 @@ sebserver.gui.entrypoint=/gui sebserver.gui.webservice.apipath=${sebserver.webservice.api.admin.endpoint} # defines the polling interval that is used to poll the webservice for client connection data on a monitored exam page -sebserver.gui.webservice.poll-interval=1000 +sebserver.gui.webservice.poll-interval=3000 sebserver.gui.webservice.mock-lms-enabled=true sebserver.gui.webservice.edx-lms-enabled=true sebserver.gui.webservice.moodle-lms-enabled=true diff --git a/src/main/resources/config/application-ws.properties b/src/main/resources/config/application-ws.properties index 9e6c6799..a8b7c3e0 100644 --- a/src/main/resources/config/application-ws.properties +++ b/src/main/resources/config/application-ws.properties @@ -10,6 +10,9 @@ sebserver.init.adminaccount.username=sebserver-admin sebserver.init.database.integrity.checks=true sebserver.init.database.integrity.try-fix=true +sebserver.webservice.distributed=false +sebserver.webservice.distributed.pingUpdate=3000 + ### webservice caching spring.cache.jcache.provider=org.ehcache.jsr107.EhcacheCachingProvider spring.cache.jcache.config=classpath:config/ehcache.xml @@ -28,6 +31,7 @@ spring.datasource.hikari.initializationFailTimeout=3000 spring.datasource.hikari.connectionTimeout=30000 spring.datasource.hikari.idleTimeout=600000 spring.datasource.hikari.maxLifetime=1800000 +spring.datasource.hikari.maximumPoolSize=500 ### webservice security spring.datasource.password=${sebserver.mariadb.password} diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 051d5ed0..ff7e222a 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -1867,6 +1867,7 @@ sebserver.seblogs.form.column.exam.endTime=End Time sebserver.seblogs.form.column.exam.endTime.tooltip=The end date and time of the exam sebserver.seblogs.action.delete=Delete Logs +sebserver.seblogs.action.export.csv=Export CSV sebserver.seblogs.delete.form.title=Delete SEB Logs sebserver.seblogs.delete.form.info=This will delete all SEB client logs from the current filtered list.
Please check carefully if all SEB client logs from the list shall be deleted.

There are currently {0} logs within the list. sebserver.seblogs.delete.action.delete=Delete All Logs diff --git a/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/ExamProctoringRoomServiceTest.java b/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/ExamProctoringRoomServiceTest.java index b13bc549..9c1874c6 100644 --- a/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/ExamProctoringRoomServiceTest.java +++ b/src/test/java/ch/ethz/seb/sebserver/webservice/integration/api/admin/ExamProctoringRoomServiceTest.java @@ -51,7 +51,8 @@ public class ExamProctoringRoomServiceTest extends AdministrationAPIIntegrationT assertFalse(runningExamsForInstitution.hasError()); final Collection collection = runningExamsForInstitution.get(); assertFalse(collection.isEmpty()); - final Exam exam = collection.iterator().next(); + final Exam exam = collection.stream().filter(e -> e.id == 2L).findAny().orElse(null); + assertNotNull(exam); assertEquals("Demo Quiz 6 (MOCKUP)", exam.name); assertEquals("2", String.valueOf(exam.id)); }