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 3f1a67a0..e523be9a 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 @@ -182,6 +182,7 @@ public final class API { public static final String EXAM_PROCTORING_COLLECTING_ROOMS_SEGMENT = "/collecting-rooms"; public static final String EXAM_PROCTORING_OPEN_BREAK_OUT_ROOM_SEGMENT = "/open"; public static final String EXAM_PROCTORING_CLOSE_ROOM_SEGMENT = "/close"; + public static final String EXAM_PROCTORING_NOTIFY_OPEN_ROOM_SEGMENT = "/notify-open-room"; public static final String EXAM_PROCTORING_RECONFIGURATION_ATTRIBUTES = "/reconfiguration-attributes"; public static final String EXAM_PROCTORING_ROOM_CONNECTIONS_PATH_SEGMENT = "/room-connections"; public static final String EXAM_PROCTORING_ACTIVATE_TOWNHALL_ROOM = "activate-towhall-room"; diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/ProctoringServlet.java b/src/main/java/ch/ethz/seb/sebserver/gui/ProctoringServlet.java index 08acad15..8701d755 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/ProctoringServlet.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/ProctoringServlet.java @@ -41,7 +41,9 @@ public class ProctoringServlet extends HttpServlet { private final Collection proctoringWindowScriptResolver; - public ProctoringServlet(final Collection proctoringWindowScriptResolver) { + public ProctoringServlet( + final Collection proctoringWindowScriptResolver) { + this.proctoringWindowScriptResolver = proctoringWindowScriptResolver; } @@ -78,6 +80,7 @@ public class ProctoringServlet extends HttpServlet { } else { resp.getOutputStream().println(script); } + } private boolean isAuthenticated( diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/content/MonitoringClientConnection.java b/src/main/java/ch/ethz/seb/sebserver/gui/content/MonitoringClientConnection.java index f9bcb8f7..0b909f0f 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/content/MonitoringClientConnection.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/content/MonitoringClientConnection.java @@ -392,14 +392,14 @@ public class MonitoringClientConnection implements TemplateComposer { .publish(); } - actionBuilder - .newAction(ActionDefinition.MONITOR_EXAM_CLIENT_CONNECTION_EXAM_ROOM_PROCTORING) - .withEntityKey(parentEntityKey) - .withExec(action -> this.monitoringProctoringService.openExamCollectionProctorScreen( - action, - connectionData)) - .noEventPropagation() - .publish(); +// actionBuilder +// .newAction(ActionDefinition.MONITOR_EXAM_CLIENT_CONNECTION_EXAM_ROOM_PROCTORING) +// .withEntityKey(parentEntityKey) +// .withExec(action -> this.monitoringProctoringService.openExamCollectionProctorScreen( +// action, +// connectionData)) +// .noEventPropagation() +// .publish(); clientConnectionDetails.setStatusChangeListener(ccd -> { this.pageService.firePageEvent( diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/page/impl/PageServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/page/impl/PageServiceImpl.java index f991cf59..1d87f60e 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/page/impl/PageServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/page/impl/PageServiceImpl.java @@ -171,8 +171,8 @@ public class PageServiceImpl implements PageService { return FormTooltipMode.INPUT; } - @Override @SuppressWarnings("unchecked") + @Override public void firePageEvent(final T event, final PageContext pageContext) { final Class typeClass = event.getClass(); final List> listeners = new ArrayList<>(); diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/session/NotifyProctoringRoomOpened.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/session/NotifyProctoringRoomOpened.java new file mode 100644 index 00000000..a24a8f2d --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/api/session/NotifyProctoringRoomOpened.java @@ -0,0 +1,41 @@ +/* + * 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.session; + +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.RestCall; + +@Lazy +@Component +@GuiProfile +public class NotifyProctoringRoomOpened extends RestCall { + + public NotifyProctoringRoomOpened() { + super(new TypeKey<>( + CallType.UNDEFINED, + EntityType.EXAM_PROCTOR_DATA, + new TypeReference() { + }), + HttpMethod.POST, + MediaType.APPLICATION_FORM_URLENCODED, + API.EXAM_PROCTORING_ENDPOINT + + API.MODEL_ID_VAR_PATH_SEGMENT + + API.EXAM_PROCTORING_NOTIFY_OPEN_ROOM_SEGMENT); + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/MonitoringProctoringService.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/MonitoringProctoringService.java index 7a76d9ce..50ebe352 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/MonitoringProctoringService.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/MonitoringProctoringService.java @@ -51,6 +51,7 @@ import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.exam.GetProctorin import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.session.GetCollectingRooms; import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.session.GetProctorRoomConnection; import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.session.IsTownhallRoomAvailable; +import ch.ethz.seb.sebserver.gui.service.remote.webservice.api.session.NotifyProctoringRoomOpened; @Lazy @Component @@ -489,6 +490,12 @@ public class MonitoringProctoringService { .getProctoringGUIService() .registerProctoringWindow(String.valueOf(room.examId), room.name, room.name); + this.pageService.getRestService().getBuilder(NotifyProctoringRoomOpened.class) + .withURIVariable(API.PARAM_MODEL_ID, String.valueOf(proctoringSettings.examId)) + .withQueryParam(ProctoringRoomConnection.ATTR_ROOM_NAME, room.name) + .call() + .onError(error -> log.error("Failed to notify proctoring room opened: ", error)); + return action; } diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/ProctoringGUIService.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/ProctoringGUIService.java index d6bbe9b5..9dd5af67 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/ProctoringGUIService.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/ProctoringGUIService.java @@ -10,6 +10,7 @@ package ch.ethz.seb.sebserver.gui.service.session.proctoring; import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.function.Consumer; @@ -202,16 +203,13 @@ public class ProctoringGUIService { roomData.roomName, error.getMessage())); closeWindow(windowName); - } else { - log.warn("No proctoring room window with name: {} found for closing.", windowName); } } public void clear() { this.collectingRoomsActionState.clear(); if (!this.openWindows.isEmpty()) { - this.openWindows - .entrySet() + new HashSet<>(this.openWindows.entrySet()) .stream() .forEach(entry -> closeRoomWindow(entry.getKey())); diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/RemoteProctoringRoomDAO.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/RemoteProctoringRoomDAO.java index aef5f198..a3f28fc3 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/RemoteProctoringRoomDAO.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/RemoteProctoringRoomDAO.java @@ -117,6 +117,13 @@ public interface RemoteProctoringRoomDAO { * @param examId the exam identifier * @param roomId the room record identifier (PK) * @return Result refer to the actual collecting room record or to an error when happened */ - Result releasePlaceInCollectingRoom(final Long examId, Long roomId); + Result releasePlaceInCollectingRoom(Long examId, Long roomId); + + /** Get currently active break-out rooms for given connectionToken + * + * @param examId The exam identifier of the connection + * @param connectionTokens The connection token of the client connection + * @return Result refer to active break-out rooms or to an error when happened */ + Result> getBreakoutRooms(String connectionToken); } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/RemoteProctoringRoomDAOImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/RemoteProctoringRoomDAOImpl.java index fdbb61b3..0f49fc08 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/RemoteProctoringRoomDAOImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/RemoteProctoringRoomDAOImpl.java @@ -268,6 +268,8 @@ public class RemoteProctoringRoomDAOImpl implements RemoteProctoringRoomDAO { final Optional room = this.remoteProctoringRoomRecordMapper.selectByExample() .where(RemoteProctoringRoomRecordDynamicSqlSupport.examId, isEqualTo(examId)) + .and(RemoteProctoringRoomRecordDynamicSqlSupport.townhallRoom, isEqualTo(0)) + .and(RemoteProctoringRoomRecordDynamicSqlSupport.breakOutConnections, isNull()) .build() .execute() .stream() @@ -306,6 +308,20 @@ public class RemoteProctoringRoomDAOImpl implements RemoteProctoringRoomDAO { .onError(TransactionHandler::rollback); } + @Override + @Transactional(readOnly = true) + public Result> getBreakoutRooms(final String connectionToken) { + return Result.tryCatch(() -> this.remoteProctoringRoomRecordMapper + .selectByExample() + .where(RemoteProctoringRoomRecordDynamicSqlSupport.townhallRoom, isEqualTo(0)) + .and(RemoteProctoringRoomRecordDynamicSqlSupport.breakOutConnections, isLike(connectionToken)) + .build() + .execute() + .stream() + .map(this::toDomainModel) + .collect(Collectors.toList())); + } + private RemoteProctoringRoom toDomainModel(final RemoteProctoringRoomRecord record) { final String breakOutConnections = record.getBreakOutConnections(); final Collection connections = StringUtils.isNotBlank(breakOutConnections) @@ -330,6 +346,8 @@ public class RemoteProctoringRoomDAOImpl implements RemoteProctoringRoomDAO { final Long roomNumber = this.remoteProctoringRoomRecordMapper.countByExample() .where(RemoteProctoringRoomRecordDynamicSqlSupport.examId, isEqualTo(examId)) + .and(RemoteProctoringRoomRecordDynamicSqlSupport.townhallRoom, isEqualTo(0)) + .and(RemoteProctoringRoomRecordDynamicSqlSupport.breakOutConnections, isNull()) .build() .execute(); diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamProctoringRoomService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamProctoringRoomService.java index dba2e0e5..0165176a 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamProctoringRoomService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamProctoringRoomService.java @@ -59,6 +59,10 @@ public interface ExamProctoringRoomService { * @return Result refer to the given exam or to an error when happened */ Result disposeRoomsForExam(Exam exam); + /** Indicates whether the town-hall for given exam is active or not + * + * @param examId the exam identifier + * @return true if the town-hall for given exam is currently actice */ boolean isTownhallRoomActive(final Long examId); /** This creates a town-hall room for a specific exam. The exam must be active and running @@ -76,6 +80,10 @@ public interface ExamProctoringRoomService { * @return Result refer to the RemoteProctoringRoom data or to an error when happened */ Result getTownhallRoomData(final Long examId); + /** Used to close a active town-hall for given exam. + * + * @param examId The exam identifier + * @return Result refer to the room key of the closed town-hall or to an error when happened. */ Result closeTownhallRoom(Long examId); /** Used to create a break out room for all active SEB clients given by the connectionTokens. @@ -109,9 +117,16 @@ public interface ExamProctoringRoomService { * @param roomName The room name * @param attributes the reconfiguration attributes * @return Result refer to an empty value or to an error when happened */ - Result sendReconfigurationInstructions( - final Long examId, - final String roomName, - final Map attributes); + Result sendReconfigurationInstructions(Long examId, String roomName, Map attributes); + + /** Notifies that a specified proctoring room has been opened by a proctor. + * + * This can be used to do instruct connection SEB clients of the room to do some initial actions, + * sending join instruction for the room to the SEB clients for example. + * + * @param examId The exam identifier of the proctoring room + * @param roomName The name of the proctoring room + * @return Result refer to void or to an error when happened */ + Result notifyRoomOpened(Long examId, String roomName); } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamProctoringService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamProctoringService.java index 7bb2be4d..67723096 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamProctoringService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ExamProctoringService.java @@ -8,13 +8,14 @@ package ch.ethz.seb.sebserver.webservice.servicelayer.session; +import java.util.Collection; import java.util.Map; -import org.apache.commons.lang3.StringUtils; - import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringRoomConnection; import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings; import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings.ProctoringServerType; +import ch.ethz.seb.sebserver.gbl.model.session.ClientConnection; +import ch.ethz.seb.sebserver.gbl.model.session.RemoteProctoringRoom; import ch.ethz.seb.sebserver.gbl.util.Result; import ch.ethz.seb.sebserver.webservice.servicelayer.session.impl.proctoring.NewRoom; @@ -43,32 +44,85 @@ public interface ExamProctoringService { String roomName, String subject); + /** Get specified proctoring room connection data for a given connected SEB client + * + * @param proctoringSettings The proctoring service settings of the exam where the client belogs to + * @param connectionToken The connection token of the SEB client connection (identification) + * @param roomName The name of the room to connect to + * @param subject The room subject (display name of the room when the client enter the room) + * @return Result refer to the proctoring room connection data or to an error when happened */ Result getClientRoomConnection( ProctoringServiceSettings proctoringSettings, String connectionToken, String roomName, String subject); + /** Used to create join-instruction attribute for joining a given room + * This attributes are added to the join-instruction that is sent to the SEB client + * + * @param proctoringConnection the proctoring room connection data of the room to join + * @return Map containing additional join-instruction attributes that are added to the join-instruction */ Map createJoinInstructionAttributes(ProctoringRoomConnection proctoringConnection); + /** Dispose or delete all rooms or meetings or other data on the proctoring service side for + * a given exam. + * + * @param examId The exam identifier + * @param proctoringSettings The proctoring service settings + * @return Result that is empty or refer to an error if happened */ Result disposeServiceRoomsForExam(Long examId, ProctoringServiceSettings proctoringSettings); - default String verifyRoomName(final String requestedRoomName, final String connectionToken) { - if (StringUtils.isNotBlank(requestedRoomName)) { - return requestedRoomName; - } - - throw new RuntimeException("Test Why: " + connectionToken); - } - + /** Creates a new collecting room on proctoring service side. + * + * @param proctoringSettings The proctoring service settings to connect to the service + * @param roomNumber the collecting room number + * @return Result refer to the new room or to an error when happened */ Result newCollectingRoom(ProctoringServiceSettings proctoringSettings, Long roomNumber); + /** Create a new break-out room on service side. + * + * @param proctoringSettings The proctoring service settings to connect to the service + * @param subject The subject of the new break-out room + * @return Result refer to the new room or to an error when happened */ Result newBreakOutRoom(ProctoringServiceSettings proctoringSettings, String subject); + /** Dispose or delete a given break-out room on proctoring service side. + * + * @param proctoringSettings The proctoring service settings to connect to the service + * @param roomName the room name + * @return Result refer to void or to an error when happened */ Result disposeBreakOutRoom(ProctoringServiceSettings proctoringSettings, String roomName); - Map getDefaultInstructionAttributes(); + /** Used to get the default SEB client proctoring reconfiguration instruction attributes for the specified + * proctoring service. + * + * @return Map containing the default SEB client instruction attributes */ + Map getDefaultReconfigInstructionAttributes(); - Map getInstructionAttributes(Map attributes); + /** Used to map SEB client proctoring reconfiguration instruction attributes from SEB Server API name + * to SEB key name. + * + * @param attributes Map containing the internal SEB Server API names of the attributes + * @return Map containing the external SEB settings attribute names */ + Map mapReconfigInstructionAttributes(Map attributes); + + /** Gets called when a proctor opened a break-out room. + * This can be used to do some post processing after a proctor opened a break-out room + * + * @param proctoringSettings The proctoring service settings to connect to the service + * @param room The room data of the break-out room + * @return Result refer to void or to an error when happened */ + Result notifyBreakOutRoomOpened(ProctoringServiceSettings proctoringSettings, RemoteProctoringRoom room); + + /** Gets called when a proctor opened a collecting room. + * This can be used to do some post processing after a proctor opened a collecting room + * + * @param proctoringSettings The proctoring service settings to connect to the service + * @param room The room data of the collecting room + * @return Result refer to void or to an error when happened */ + Result notifyCollectingRoomOpened( + ProctoringServiceSettings proctoringSettings, + RemoteProctoringRoom room, + Collection clientConnections); } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionControlTask.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionControlTask.java index 49af6ff9..6fdddc58 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionControlTask.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/ExamSessionControlTask.java @@ -101,7 +101,7 @@ class ExamSessionControlTask implements DisposableBean { final String updateId = this.examUpdateHandler.createUpdateId(); if (log.isDebugEnabled()) { - log.debug("Run exam runtime update task with Id: {}", updateId); + log.debug("Run exam update task with Id: {}", updateId); } controlExamStart(updateId); @@ -121,8 +121,8 @@ class ExamSessionControlTask implements DisposableBean { } private void controlExamStart(final String updateId) { - if (log.isDebugEnabled()) { - log.debug("Check starting exams: {}", updateId); + if (log.isTraceEnabled()) { + log.trace("Check starting exams: {}", updateId); } try { @@ -145,8 +145,8 @@ class ExamSessionControlTask implements DisposableBean { } private void controlExamEnd(final String updateId) { - if (log.isDebugEnabled()) { - log.debug("Check ending exams: {}", updateId); + if (log.isTraceEnabled()) { + log.trace("Check ending exams: {}", updateId); } try { 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 16b8da96..70e15dd6 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 @@ -280,6 +280,8 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService cc.getExamId(), cc.getRemoteProctoringRoomId()); + this.cleanupBreakOutRooms(cc); + this.clientConnectionDAO .removeFromProctoringRoom(cc.getId(), cc.getConnectionToken()) .onError(error -> log.error("Failed to remove client connection form room: ", error)) @@ -334,6 +336,37 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService return Result.ofRuntimeError("No active town-hall for exam: " + examId); } + @Override + public Result notifyRoomOpened(final Long examId, final String roomName) { + return Result.tryCatch(() -> { + final ProctoringServiceSettings proctoringSettings = this.examAdminService + .getProctoringServiceSettings(examId) + .getOrThrow(); + + final ExamProctoringService examProctoringService = this.examAdminService + .getExamProctoringService(proctoringSettings.serverType) + .getOrThrow(); + + final RemoteProctoringRoom room = this.remoteProctoringRoomDAO + .getRoom(examId, roomName) + .getOrThrow(); + + if (room.townhallRoom || !room.breakOutConnections.isEmpty()) { + examProctoringService + .notifyBreakOutRoomOpened(proctoringSettings, room) + .getOrThrow(); + } else { + final Collection clientConnections = this.clientConnectionDAO + .getCollectingRoomConnections(examId, roomName) + .getOrThrow(); + + examProctoringService + .notifyCollectingRoomOpened(proctoringSettings, room, clientConnections) + .getOrThrow(); + } + }); + } + private void closeTownhall( final Long examId, final ProctoringServiceSettings proctoringSettings, @@ -348,7 +381,7 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService this.sendReconfigurationInstructions( examId, connectionTokens, - examProctoringService.getDefaultInstructionAttributes()); + examProctoringService.getDefaultReconfigInstructionAttributes()); // Close and delete town-hall room this.remoteProctoringRoomDAO @@ -383,7 +416,35 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService this.sendReconfigurationInstructions( examId, connectionTokens, - examProctoringService.getDefaultInstructionAttributes()); + examProctoringService.getDefaultReconfigInstructionAttributes()); + } + + private void cleanupBreakOutRooms(final ClientConnectionRecord cc) { + + // check if there is a break-out room with matching single connection token + final Collection roomsToCleanup = this.remoteProctoringRoomDAO + .getBreakoutRooms(cc.getConnectionToken()) + .getOrThrow(); + + roomsToCleanup.stream().forEach(room -> { + final ExamProctoringService examProctoringService = this.examAdminService + .getExamProctoringService(room.examId) + .getOrThrow(); + + final ProctoringServiceSettings proctoringSettings = this.examAdminService + .getProctoringServiceSettings(room.examId) + .getOrThrow(); + + // Dispose the proctoring room on service side + examProctoringService + .disposeBreakOutRoom(proctoringSettings, room.name) + .getOrThrow(); + + // Delete room on persistent + this.remoteProctoringRoomDAO + .deleteRoom(room.id) + .getOrThrow(); + }); } private void closeBreakOutRoom( @@ -396,7 +457,7 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService this.sendReconfigurationInstructions( examId, remoteProctoringRoom.breakOutConnections, - examProctoringService.getDefaultInstructionAttributes()); + examProctoringService.getDefaultReconfigInstructionAttributes()); // Dispose the proctoring room on service side examProctoringService @@ -458,7 +519,7 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService sendReconfigurationInstructions( examId, connectionTokens, - examProctoringService.getInstructionAttributes(attributes)); + examProctoringService.mapReconfigInstructionAttributes(attributes)); }); } @@ -523,7 +584,7 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService .getClientRoomConnection( proctoringSettings, connectionToken, - examProctoringService.verifyRoomName(roomName, connectionToken), + roomName, (StringUtils.isNotBlank(subject)) ? subject : roomName) .onError(error -> log.error( "Failed to get client room connection data for {} cause: {}", diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/JitsiProctoringService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/JitsiProctoringService.java index aa69eb15..3adb9627 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/JitsiProctoringService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/JitsiProctoringService.java @@ -14,6 +14,7 @@ import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.Base64; import java.util.Base64.Encoder; +import java.util.Collection; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -50,8 +51,10 @@ import ch.ethz.seb.sebserver.gbl.model.exam.Exam; import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringRoomConnection; import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings; import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings.ProctoringServerType; +import ch.ethz.seb.sebserver.gbl.model.session.ClientConnection; import ch.ethz.seb.sebserver.gbl.model.session.ClientConnectionData; import ch.ethz.seb.sebserver.gbl.model.session.ClientInstruction; +import ch.ethz.seb.sebserver.gbl.model.session.RemoteProctoringRoom; import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; import ch.ethz.seb.sebserver.gbl.util.Cryptor; import ch.ethz.seb.sebserver.gbl.util.Result; @@ -207,12 +210,12 @@ public class JitsiProctoringService implements ExamProctoringService { } @Override - public Map getDefaultInstructionAttributes() { + public Map getDefaultReconfigInstructionAttributes() { return SEB_INSTRUCTION_DEFAULTS; } @Override - public Map getInstructionAttributes(final Map attributes) { + public Map mapReconfigInstructionAttributes(final Map attributes) { final Map result = attributes .entrySet() .stream() @@ -310,6 +313,28 @@ public class JitsiProctoringService implements ExamProctoringService { }); } + @Override + public Result notifyBreakOutRoomOpened( + final ProctoringServiceSettings proctoringSettings, + final RemoteProctoringRoom room) { + + // Does nothing since the join instructions for break-out rooms has been sent by the overal service + + return Result.EMPTY; + } + + @Override + public Result notifyCollectingRoomOpened( + final ProctoringServiceSettings proctoringSettings, + final RemoteProctoringRoom room, + final Collection clientConnections) { + + // Does nothing at the moment + // TODO check if we need something similar for Jitsi as it is implemented for Zoom + // --> send join instructions to all involved client connections except them in one to one room. + return Result.EMPTY; + } + protected Result createProctoringConnection( final String connectionToken, final String url, 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 ebccb028..1c75ca86 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 @@ -13,6 +13,7 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; import java.util.Base64.Encoder; +import java.util.Collection; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -36,6 +37,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientResponseException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; @@ -56,8 +58,10 @@ import ch.ethz.seb.sebserver.gbl.client.ClientCredentials; import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringRoomConnection; import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings; import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings.ProctoringServerType; +import ch.ethz.seb.sebserver.gbl.model.session.ClientConnection; import ch.ethz.seb.sebserver.gbl.model.session.ClientConnectionData; import ch.ethz.seb.sebserver.gbl.model.session.ClientInstruction; +import ch.ethz.seb.sebserver.gbl.model.session.ClientInstruction.InstructionType; import ch.ethz.seb.sebserver.gbl.model.session.RemoteProctoringRoom; import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; import ch.ethz.seb.sebserver.gbl.util.Cryptor; @@ -68,6 +72,7 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.Authorization import ch.ethz.seb.sebserver.webservice.servicelayer.dao.RemoteProctoringRoomDAO; import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamProctoringService; import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamSessionService; +import ch.ethz.seb.sebserver.webservice.servicelayer.session.SEBClientInstructionService; import ch.ethz.seb.sebserver.webservice.servicelayer.session.impl.proctoring.ZoomRoomRequestResponse.CreateMeetingRequest; import ch.ethz.seb.sebserver.webservice.servicelayer.session.impl.proctoring.ZoomRoomRequestResponse.CreateUserRequest; import ch.ethz.seb.sebserver.webservice.servicelayer.session.impl.proctoring.ZoomRoomRequestResponse.MeetingResponse; @@ -99,7 +104,7 @@ public class ZoomProctoringService implements ExamProctoringService { ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.ZOOM_ALLOW_CHAT)) .stream().collect(Collectors.toMap(Tuple::get_1, Tuple::get_2))); - private static final Map SEB_INSTRUCTION_DEFAULTS = Utils.immutableMapOf(Arrays.asList( + private static final Map SEB_RECONFIG_INSTRUCTION_DEFAULTS = Utils.immutableMapOf(Arrays.asList( new Tuple<>( ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.ZOOM_RECEIVE_AUDIO, Constants.FALSE_STRING), @@ -119,6 +124,7 @@ public class ZoomProctoringService implements ExamProctoringService { private final ZoomRestTemplate zoomRestTemplate; private final RemoteProctoringRoomDAO remoteProctoringRoomDAO; private final AuthorizationService authorizationService; + private final SEBClientInstructionService sebInstructionService; public ZoomProctoringService( final ExamSessionService examSessionService, @@ -127,7 +133,8 @@ public class ZoomProctoringService implements ExamProctoringService { final AsyncService asyncService, final JSONMapper jsonMapper, final RemoteProctoringRoomDAO remoteProctoringRoomDAO, - final AuthorizationService authorizationService) { + final AuthorizationService authorizationService, + final SEBClientInstructionService sebInstructionService) { this.examSessionService = examSessionService; this.clientHttpRequestFactoryService = clientHttpRequestFactoryService; @@ -137,6 +144,7 @@ public class ZoomProctoringService implements ExamProctoringService { this.zoomRestTemplate = new ZoomRestTemplate(this); this.remoteProctoringRoomDAO = remoteProctoringRoomDAO; this.authorizationService = authorizationService; + this.sebInstructionService = sebInstructionService; } @Override @@ -390,7 +398,7 @@ public class ZoomProctoringService implements ExamProctoringService { this.deleteAdHocMeeting( proctoringSettings, credentials, - roomName, + additionalZoomRoomData.meeting_id, additionalZoomRoomData.user_id) .getOrThrow(); @@ -404,12 +412,12 @@ public class ZoomProctoringService implements ExamProctoringService { } @Override - public Map getDefaultInstructionAttributes() { - return SEB_INSTRUCTION_DEFAULTS; + public Map getDefaultReconfigInstructionAttributes() { + return SEB_RECONFIG_INSTRUCTION_DEFAULTS; } @Override - public Map getInstructionAttributes(final Map attributes) { + public Map mapReconfigInstructionAttributes(final Map attributes) { return attributes.entrySet().stream() .map(entry -> new Tuple<>( SEB_API_NAME_INSTRUCTION_NAME_MAPPING.getOrDefault(entry.getKey(), entry.getKey()), @@ -417,6 +425,62 @@ public class ZoomProctoringService implements ExamProctoringService { .collect(Collectors.toMap(Tuple::get_1, Tuple::get_2)); } + @Override + public Result notifyBreakOutRoomOpened( + final ProctoringServiceSettings proctoringSettings, + final RemoteProctoringRoom room) { + + // Not needed for Zoom integration so far + + return Result.EMPTY; + } + + @Override + public Result notifyCollectingRoomOpened( + final ProctoringServiceSettings proctoringSettings, + final RemoteProctoringRoom room, + final Collection clientConnections) { + + return Result.tryCatch(() -> { + + if (this.remoteProctoringRoomDAO.isTownhallRoomActive(proctoringSettings.examId)) { + // do nothing is the town-hall of this exam is open. The clients will automatically join + // the meeting once the town-hall has been closed + return; + } + + final ProctoringRoomConnection proctoringRoomConnection = this.getProctorRoomConnection( + proctoringSettings, + room.name, + room.subject) + .getOrThrow(); + + clientConnections.stream() + .forEach(cc -> sendJoinInstruction( + proctoringSettings.examId, + cc.connectionToken, + proctoringRoomConnection)); + }); + } + + private void sendJoinInstruction( + final Long examId, + final String connectionToken, + final ProctoringRoomConnection proctoringConnection) { + + final Map attributes = this + .createJoinInstructionAttributes(proctoringConnection); + + this.sebInstructionService + .registerInstruction( + examId, + InstructionType.SEB_PROCTORING, + attributes, + connectionToken, + true) + .onError(error -> log.error("Failed to send join instruction: {}", connectionToken, error)); + } + private Result createAdHocMeeting( final String roomName, final String subject, @@ -450,8 +514,6 @@ public class ZoomProctoringService implements ExamProctoringService { createMeeting.getBody(), MeetingResponse.class); - // TODO start the meeting automatically ??? - // Create NewRoom data with all needed information to store persistent final AdditionalZoomRoomData additionalZoomRoomData = new AdditionalZoomRoomData( meetingResponse.id, @@ -472,7 +534,7 @@ public class ZoomProctoringService implements ExamProctoringService { private Result deleteAdHocMeeting( final ProctoringServiceSettings proctoringSettings, final ClientCredentials credentials, - final String meetingId, + final Long meetingId, final String userId) { return Result.tryCatch(() -> { @@ -565,6 +627,7 @@ public class ZoomProctoringService implements ExamProctoringService { private static final String API_ZOOM_ROOM_USER = "SEBProctoringRoomUser"; private static final String API_CREATE_MEETING_ENDPOINT = "v2/users/{userid}/meetings"; private static final String API_DELETE_MEETING_ENDPOINT = "v2/meetings/{meetingid}"; + private static final String API_END_MEETING_ENDPOINT = "v2/meetings/{meetingid}/status"; private final ZoomProctoringService zoomProctoringService; private final RestTemplate restTemplate; @@ -671,7 +734,38 @@ public class ZoomProctoringService implements ExamProctoringService { public ResponseEntity deleteMeeting( final String zoomServerUrl, final ClientCredentials credentials, - final String meetingId) { + final Long meetingId) { + + // try to set set meeting status to ended first + try { + + final String url = UriComponentsBuilder + .fromUriString(zoomServerUrl) + .path(API_END_MEETING_ENDPOINT) + .buildAndExpand(meetingId) + .toUriString(); + + final HttpHeaders headers = getHeaders(credentials); + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + + final ResponseEntity exchange = exchange( + url, + HttpMethod.PUT, + "{\"action\": \"end\"}", + headers); + + if (log.isDebugEnabled() && exchange.getStatusCodeValue() != 204) { + log.debug("Failed to set meeting to end state. Meeting: {}, http: {}", + meetingId, + exchange.getStatusCodeValue()); + } + + } catch (final Exception e) { + log.warn("Failed to end Zoom ad-hoc meeting: {} cause: {} / {}", + meetingId, + e.getMessage(), + (e.getCause() != null) ? e.getCause().getMessage() : Constants.EMPTY_NOTE); + } try { @@ -684,7 +778,7 @@ public class ZoomProctoringService implements ExamProctoringService { return exchange(url, HttpMethod.DELETE, credentials); } catch (final Exception e) { - log.error("Failed to delete Zoom ad-hoc meeting: {} cause: {} / {}", + log.warn("Failed to delete Zoom ad-hoc meeting: {} cause: {} / {}", meetingId, e.getMessage(), (e.getCause() != null) ? e.getCause().getMessage() : Constants.EMPTY_NOTE); @@ -747,21 +841,27 @@ public class ZoomProctoringService implements ExamProctoringService { ? new HttpEntity<>(body, httpHeaders) : new HttpEntity<>(httpHeaders); - final ResponseEntity result = this.restTemplate.exchange( - url, //"https://ethz.zoom.us/v2/users?Fstatus=active&page_size=30&page_number=1&data_type=Json", - method, - httpEntity, - String.class); + try { + final ResponseEntity result = this.restTemplate.exchange( + url, + method, + httpEntity, + String.class); - if (result.getStatusCode() != HttpStatus.OK && result.getStatusCode() != HttpStatus.CREATED) { - log.warn("Error response on Zoom API call to {} response status: {}", url, result.getStatusCode()); + if (result.getStatusCode().value() >= 400) { + log.warn("Error response on Zoom API call to {} response status: {}", url, + result.getStatusCode()); + } + + return result; + } catch (final RestClientResponseException rce) { + return ResponseEntity + .status(rce.getRawStatusCode()) + .body(rce.getResponseBodyAsString()); } - - return result; }); return protectedRunResult.getOrThrow(); } - } @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ZoomRoomRequestResponse.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ZoomRoomRequestResponse.java index 4560dbce..691500af 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ZoomRoomRequestResponse.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ZoomRoomRequestResponse.java @@ -142,6 +142,7 @@ public interface ZoomRoomRequestResponse { @JsonProperty final int jbh_time = 0; @JsonProperty final boolean use_pmi = false; @JsonProperty final String audio = "voip"; + @JsonProperty final boolean waiting_room = false; } } 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 52d14ec3..1be45e90 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 @@ -119,6 +119,26 @@ public class ExamProctoringController { .getOrThrow(); } + @RequestMapping( + path = API.MODEL_ID_VAR_PATH_SEGMENT + + API.EXAM_PROCTORING_NOTIFY_OPEN_ROOM_SEGMENT, + method = RequestMethod.POST, + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public void notifyProctoringRoomOpened( + @RequestParam( + name = API.PARAM_INSTITUTION_ID, + required = true, + defaultValue = UserService.USERS_INSTITUTION_AS_DEFAULT) final Long institutionId, + @PathVariable(name = API.PARAM_MODEL_ID) final Long examId, + @RequestParam(name = ProctoringRoomConnection.ATTR_ROOM_NAME, required = true) final String roomName) { + + checkAccess(institutionId, examId); + this.examSessionService.getRunningExam(examId) + .flatMap(this.authorizationService::checkRead) + .flatMap(exam -> this.examProcotringRoomService.notifyRoomOpened(exam.id, roomName)) + .getOrThrow(); + } + @RequestMapping( path = API.MODEL_ID_VAR_PATH_SEGMENT + API.EXAM_PROCTORING_ROOM_CONNECTIONS_PATH_SEGMENT, diff --git a/src/main/resources/ch/ethz/seb/sebserver/gui/service/session/proctoring/zoomWindow.html b/src/main/resources/ch/ethz/seb/sebserver/gui/service/session/proctoring/zoomWindow.html index 9927086b..b0dd4266 100644 --- a/src/main/resources/ch/ethz/seb/sebserver/gui/service/session/proctoring/zoomWindow.html +++ b/src/main/resources/ch/ethz/seb/sebserver/gui/service/session/proctoring/zoomWindow.html @@ -99,7 +99,7 @@ }) window.addEventListener('unload', () => { - ZoomMtg.leaveMeeting({}); + ZoomMtg.endMeeting({}); }); diff --git a/src/test/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/ZoomWindowScriptResolverTest.java b/src/test/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/ZoomWindowScriptResolverTest.java index 0b3663ab..17d4a852 100644 --- a/src/test/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/ZoomWindowScriptResolverTest.java +++ b/src/test/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/ZoomWindowScriptResolverTest.java @@ -166,7 +166,7 @@ public class ZoomWindowScriptResolverTest { + " })\r\n" + " \r\n" + " window.addEventListener('unload', () => {\r\n" - + " ZoomMtg.leaveMeeting({});\r\n" + + " ZoomMtg.endMeeting({});\r\n" + " });\r\n" + " \r\n" + " \r\n"