Index: lams_common/src/java/org/lamsfoundation/lams/web/controller/AbstractTimeLimitWebsocketServer.java =================================================================== diff -u -r0ebaba46f0ce87b5138e8e70cc1965887c1c7787 -r705c0f72b765849974bfa0d9f8b04797619e8da7 --- lams_common/src/java/org/lamsfoundation/lams/web/controller/AbstractTimeLimitWebsocketServer.java (.../AbstractTimeLimitWebsocketServer.java) (revision 0ebaba46f0ce87b5138e8e70cc1965887c1c7787) +++ lams_common/src/java/org/lamsfoundation/lams/web/controller/AbstractTimeLimitWebsocketServer.java (.../AbstractTimeLimitWebsocketServer.java) (revision 705c0f72b765849974bfa0d9f8b04797619e8da7) @@ -286,17 +286,20 @@ public void unregisterUser(Session websocket, CloseReason reason) { Long toolContentID = Long .valueOf(websocket.getRequestParameterMap().get(AttributeNames.PARAM_TOOL_CONTENT_ID).get(0)); - websockets.get(toolContentID).remove(websocket); + Set sessionWebsockets = websockets.get(toolContentID); + if (sessionWebsockets != null) { + websockets.get(toolContentID).remove(websocket); - if (log.isDebugEnabled()) { - // If there was something wrong with the connection, put it into logs. - log.debug("User " + websocket.getUserPrincipal().getName() + " left activity with tool content ID: " - + toolContentID - + (!(reason.getCloseCode().equals(CloseCodes.GOING_AWAY) - || reason.getCloseCode().equals(CloseCodes.NORMAL_CLOSURE)) - ? ". Abnormal close. Code: " + reason.getCloseCode() + ". Reason: " - + reason.getReasonPhrase() - : "")); + if (log.isDebugEnabled()) { + // If there was something wrong with the connection, put it into logs. + log.debug("User " + websocket.getUserPrincipal().getName() + " left activity with tool content ID: " + + toolContentID + + (!(reason.getCloseCode().equals(CloseCodes.GOING_AWAY) + || reason.getCloseCode().equals(CloseCodes.NORMAL_CLOSURE)) + ? ". Abnormal close. Code: " + reason.getCloseCode() + ". Reason: " + + reason.getReasonPhrase() + : "")); + } } } Index: lams_tool_assessment/src/java/org/lamsfoundation/lams/tool/assessment/web/controller/LearningWebsocketServer.java =================================================================== diff -u -r0ebaba46f0ce87b5138e8e70cc1965887c1c7787 -r705c0f72b765849974bfa0d9f8b04797619e8da7 --- lams_tool_assessment/src/java/org/lamsfoundation/lams/tool/assessment/web/controller/LearningWebsocketServer.java (.../LearningWebsocketServer.java) (revision 0ebaba46f0ce87b5138e8e70cc1965887c1c7787) +++ lams_tool_assessment/src/java/org/lamsfoundation/lams/tool/assessment/web/controller/LearningWebsocketServer.java (.../LearningWebsocketServer.java) (revision 705c0f72b765849974bfa0d9f8b04797619e8da7) @@ -26,7 +26,7 @@ private static final Logger log = Logger.getLogger(LearningWebsocketServer.class); - private static IAssessmentService assessmentService; + private IAssessmentService assessmentService; public LearningWebsocketServer() { if (assessmentService == null) { @@ -38,9 +38,9 @@ @Override protected Logger getLog() { - return log; + return LearningWebsocketServer.log; } - + /** * Gets settings from DB. */ Index: lams_tool_doku/src/java/org/lamsfoundation/lams/tool/dokumaran/web/controller/LearningWebsocketServer.java =================================================================== diff -u -r0ebaba46f0ce87b5138e8e70cc1965887c1c7787 -r705c0f72b765849974bfa0d9f8b04797619e8da7 --- lams_tool_doku/src/java/org/lamsfoundation/lams/tool/dokumaran/web/controller/LearningWebsocketServer.java (.../LearningWebsocketServer.java) (revision 0ebaba46f0ce87b5138e8e70cc1965887c1c7787) +++ lams_tool_doku/src/java/org/lamsfoundation/lams/tool/dokumaran/web/controller/LearningWebsocketServer.java (.../LearningWebsocketServer.java) (revision 705c0f72b765849974bfa0d9f8b04797619e8da7) @@ -26,7 +26,7 @@ private static final Logger log = Logger.getLogger(LearningWebsocketServer.class); - private static IDokumaranService dokumaranService; + private IDokumaranService dokumaranService; public LearningWebsocketServer() { if (dokumaranService == null) { @@ -38,7 +38,7 @@ @Override protected Logger getLog() { - return log; + return LearningWebsocketServer.log; } /** Index: lams_tool_scratchie/conf/language/lams/ApplicationResources.properties =================================================================== diff -u -r661711eac0fc4cd343f1507be6544771ee74f947 -r705c0f72b765849974bfa0d9f8b04797619e8da7 --- lams_tool_scratchie/conf/language/lams/ApplicationResources.properties (.../ApplicationResources.properties) (revision 661711eac0fc4cd343f1507be6544771ee74f947) +++ lams_tool_scratchie/conf/language/lams/ApplicationResources.properties (.../ApplicationResources.properties) (revision 705c0f72b765849974bfa0d9f8b04797619e8da7) @@ -179,15 +179,15 @@ label.burning.question = Burning question? label.time.limit = Time limit (minutes) label.countdown.time.left = Time left -label.time.is.over = Time is over. Processing your answers... +label.time.is.over = Time is over. Processing answers... label.are.you.ready = You are going to participate in activity that has time limitation. Are you ready to start? label.ok = OK label.count = Count label.like = Like label.unlike = Unlike label.general.burning.question = General burning question label.waiting.for.leader.launch.time.limit = Leader has not started the activity. Please wait until he commences it. -label.waiting.for.leader.submit.notebook = Time limit set by teacher is expired. Please wait until a group leader submits notebook. +label.waiting.for.leader.submit.notebook = Please wait until a group leader submits notebook. label.authoring.advanced.shuffle.items = Shuffle questions label.summary.downloaded = Excel file downloaded. label.number.groups.finished = Number of groups submitted Index: lams_tool_scratchie/conf/language/lams/ApplicationResources_en_AU.properties =================================================================== diff -u -r661711eac0fc4cd343f1507be6544771ee74f947 -r705c0f72b765849974bfa0d9f8b04797619e8da7 --- lams_tool_scratchie/conf/language/lams/ApplicationResources_en_AU.properties (.../ApplicationResources_en_AU.properties) (revision 661711eac0fc4cd343f1507be6544771ee74f947) +++ lams_tool_scratchie/conf/language/lams/ApplicationResources_en_AU.properties (.../ApplicationResources_en_AU.properties) (revision 705c0f72b765849974bfa0d9f8b04797619e8da7) @@ -179,15 +179,15 @@ label.burning.question = Burning question? label.time.limit = Time limit (minutes) label.countdown.time.left = Time left -label.time.is.over = Time is over. Processing your answers... +label.time.is.over = Time is over. Processing answers... label.are.you.ready = You are going to participate in activity that has time limitation. Are you ready to start? label.ok = OK label.count = Count label.like = Like label.unlike = Unlike label.general.burning.question = General burning question label.waiting.for.leader.launch.time.limit = Leader has not started the activity. Please wait until he commences it. -label.waiting.for.leader.submit.notebook = Time limit set by teacher is expired. Please wait until a group leader submits notebook. +label.waiting.for.leader.submit.notebook = Please wait until a group leader submits notebook. label.authoring.advanced.shuffle.items = Shuffle questions label.summary.downloaded = Excel file downloaded. label.number.groups.finished = Number of groups submitted Index: lams_tool_scratchie/src/java/org/lamsfoundation/lams/tool/scratchie/service/IScratchieService.java =================================================================== diff -u -r682279f3b246b3293dd9a4b550a06767949499ac -r705c0f72b765849974bfa0d9f8b04797619e8da7 --- lams_tool_scratchie/src/java/org/lamsfoundation/lams/tool/scratchie/service/IScratchieService.java (.../IScratchieService.java) (revision 682279f3b246b3293dd9a4b550a06767949499ac) +++ lams_tool_scratchie/src/java/org/lamsfoundation/lams/tool/scratchie/service/IScratchieService.java (.../IScratchieService.java) (revision 705c0f72b765849974bfa0d9f8b04797619e8da7) @@ -24,6 +24,7 @@ package org.lamsfoundation.lams.tool.scratchie.service; import java.io.IOException; +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; import java.util.Map; @@ -47,7 +48,6 @@ import org.lamsfoundation.lams.tool.service.ICommonToolService; import org.lamsfoundation.lams.usermanagement.User; import org.lamsfoundation.lams.util.excel.ExcelSheet; -import org.quartz.SchedulerException; /** * Interface that defines the contract that all ShareScratchie service provider must follow. @@ -122,12 +122,11 @@ /** * Stores date when user has started activity with time limit. - * - * @param sessionId - * @throws SchedulerException */ - void launchTimeLimit(Long sessionId) throws SchedulerException; + LocalDateTime launchTimeLimit(long toolContentId, int userId); + boolean checkTimeLimitExceeded(long toolContentId, int userId); + /** * Checks if non-leaders should still wait for leader to submit notebook. * @@ -293,11 +292,7 @@ void recalculateMarkForSession(Long sessionId, boolean isPropagateToGradebook); /** - * Mark all users in agroup as ScratchingFinished so that users can't continue scratching after this. - * - * @param toolSessionId - * @throws IOException - * @throws JSONException + * Mark all users in a group as ScratchingFinished so that users can't continue scratching after this. */ void setScratchingFinished(Long toolSessionId) throws IOException; Index: lams_tool_scratchie/src/java/org/lamsfoundation/lams/tool/scratchie/service/ScratchieServiceImpl.java =================================================================== diff -u -r64bfad846c475db43b1b303e0df03735f39d34ce -r705c0f72b765849974bfa0d9f8b04797619e8da7 --- lams_tool_scratchie/src/java/org/lamsfoundation/lams/tool/scratchie/service/ScratchieServiceImpl.java (.../ScratchieServiceImpl.java) (revision 64bfad846c475db43b1b303e0df03735f39d34ce) +++ lams_tool_scratchie/src/java/org/lamsfoundation/lams/tool/scratchie/service/ScratchieServiceImpl.java (.../ScratchieServiceImpl.java) (revision 705c0f72b765849974bfa0d9f8b04797619e8da7) @@ -27,6 +27,8 @@ import java.security.InvalidParameterException; import java.sql.Timestamp; import java.text.SimpleDateFormat; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -103,6 +105,7 @@ import org.lamsfoundation.lams.tool.scratchie.model.ScratchieSession; import org.lamsfoundation.lams.tool.scratchie.model.ScratchieUser; import org.lamsfoundation.lams.tool.scratchie.util.ScratchieItemComparator; +import org.lamsfoundation.lams.tool.scratchie.web.controller.LearningWebsocketServer; import org.lamsfoundation.lams.tool.service.ICommonScratchieService; import org.lamsfoundation.lams.tool.service.ILamsToolService; import org.lamsfoundation.lams.tool.service.IQbToolService; @@ -351,17 +354,33 @@ } @Override - public void launchTimeLimit(Long sessionId) { - ScratchieSession session = getScratchieSessionBySessionId(sessionId); - int timeLimit = session.getScratchie().getTimeLimit(); - if (timeLimit == 0) { - return; + public boolean checkTimeLimitExceeded(long toolContentId, int userId) { + Long secondsLeft = LearningWebsocketServer.getSecondsLeft(toolContentId, userId); + return secondsLeft != null && secondsLeft.equals(0L); + } + + @Override + public LocalDateTime launchTimeLimit(long toolContentId, int userId) { + ScratchieUser user = getUserByUserIDAndContentID(Integer.valueOf(userId).longValue(), toolContentId); + if (user == null) { + return null; } - //store timeLimitLaunchedDate into DB - Date timeLimitLaunchedDate = new Date(); - session.setTimeLimitLaunchedDate(timeLimitLaunchedDate); - scratchieSessionDao.saveObject(session); + ScratchieSession session = user.getSession(); + Date launchDate = session.getTimeLimitLaunchedDate(); + if (launchDate == null) { + if (!session.isUserGroupLeader(user.getUid())) { + // only leader launches time limit + return null; + } + + //store timeLimitLaunchedDate into DB + launchDate = new Date(); + session.setTimeLimitLaunchedDate(launchDate); + scratchieSessionDao.saveObject(session); + } + + return launchDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); } @Override Index: lams_tool_scratchie/src/java/org/lamsfoundation/lams/tool/scratchie/web/controller/LearningController.java =================================================================== diff -u -rea8629a2ce87bf6bf4b91e1b1d47b908a3dd2e74 -r705c0f72b765849974bfa0d9f8b04797619e8da7 --- lams_tool_scratchie/src/java/org/lamsfoundation/lams/tool/scratchie/web/controller/LearningController.java (.../LearningController.java) (revision ea8629a2ce87bf6bf4b91e1b1d47b908a3dd2e74) +++ lams_tool_scratchie/src/java/org/lamsfoundation/lams/tool/scratchie/web/controller/LearningController.java (.../LearningController.java) (revision 705c0f72b765849974bfa0d9f8b04797619e8da7) @@ -76,7 +76,6 @@ import org.lamsfoundation.lams.web.session.SessionManager; import org.lamsfoundation.lams.web.util.AttributeNames; import org.lamsfoundation.lams.web.util.SessionMap; -import org.quartz.SchedulerException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Controller; @@ -103,10 +102,12 @@ /** * Read scratchie data from database and put them into HttpSession. + * + * @throws IOException */ @RequestMapping("/start") private String start(HttpServletRequest request, HttpServletResponse response, @RequestParam Long toolSessionID) - throws ScratchieApplicationException { + throws ScratchieApplicationException, IOException { ToolAccessMode mode = WebUtil.readToolAccessModeParam(request, AttributeNames.PARAM_MODE, true); ScratchieSession toolSession = scratchieService.getScratchieSessionBySessionId(toolSessionID); @@ -237,87 +238,87 @@ redirectURL = WebUtil.appendParameterToURL(redirectURL, AttributeNames.ATTR_MODE, mode.toString()); return redirectURL; - // show results page - } else if (isShowResults) { + } + // show results page + if (isShowResults) { String redirectURL = "redirect:showResults.do"; redirectURL = WebUtil.appendParameterToURL(redirectURL, ScratchieConstants.ATTR_SESSION_MAP_ID, sessionMap.getSessionID()); redirectURL = WebUtil.appendParameterToURL(redirectURL, AttributeNames.ATTR_MODE, mode.toString()); return redirectURL; - // show learning.jsp page - } else { + } - // time limit feature - boolean isTimeLimitEnabled = isUserLeader && !isScratchingFinished && scratchie.getTimeLimit() != 0; - boolean isTimeLimitNotLaunched = toolSession.getTimeLimitLaunchedDate() == null; - long secondsLeft = 1; - if (isTimeLimitEnabled) { - // if user has pressed OK button already - calculate remaining time, and full time otherwise - secondsLeft = isTimeLimitNotLaunched ? scratchie.getTimeLimit() * 60 - : scratchie.getTimeLimit() * 60 - - (System.currentTimeMillis() - toolSession.getTimeLimitLaunchedDate().getTime()) - / 1000; - // change negative number or zero to 1 so it can autosubmit results - secondsLeft = Math.max(1, secondsLeft); + // check time limits + if (scratchie.getTimeLimit() != 0 && !mode.isTeacher()) { + + // show waitForLeaderLaunchTimeLimit page if the leader hasn't started activity + if (!isUserLeader && toolSession.getTimeLimitLaunchedDate() == null) { + request.setAttribute(ScratchieConstants.ATTR_WAITING_MESSAGE_KEY, + "label.waiting.for.leader.launch.time.limit"); + return "pages/learning/waitForLeaderTimeLimit"; } - request.setAttribute(ScratchieConstants.ATTR_IS_TIME_LIMIT_ENABLED, isTimeLimitEnabled); - request.setAttribute(ScratchieConstants.ATTR_IS_TIME_LIMIT_NOT_LAUNCHED, isTimeLimitNotLaunched); - request.setAttribute(ScratchieConstants.ATTR_SECONDS_LEFT, secondsLeft); - // in case we can't show learning.jsp to non-leaders forward them to the waitForLeaderTimeLimit page - if (!isUserLeader && scratchie.getTimeLimit() != 0 && !mode.isTeacher()) { + // if the time limit is over and the leader hasn't submitted notebook or burning questions (thus + // non-leaders should wait) - show waitForLeaderFinish page + if (!isUserLeader && isScratchingFinished && isWaitingForLeaderToSubmitNotebook) { + request.setAttribute(ScratchieConstants.ATTR_WAITING_MESSAGE_KEY, + "label.waiting.for.leader.submit.notebook"); + return "pages/learning/waitForLeaderTimeLimit"; + } - // show waitForLeaderLaunchTimeLimit page if the leader hasn't started activity or hasn't pressed OK - // button to launch time limit - if (toolSession.getTimeLimitLaunchedDate() == null) { - request.setAttribute(ScratchieConstants.ATTR_WAITING_MESSAGE_KEY, - "label.waiting.for.leader.launch.time.limit"); - return "pages/learning/waitForLeaderTimeLimit"; + // check if the time limit is exceeded + boolean isTimeLimitExceeded = scratchieService.checkTimeLimitExceeded(scratchie.getContentId(), + groupLeader.getUserId().intValue()); + + if (isTimeLimitExceeded) { + // first learner, even non-leader, who detects that scratching is finished (probably after time expired refresh) + // marks scratching as finished (but does not send refresh to everyone as they are already refreshing on time expired) + if (!isScratchingFinished) { + scratchieService.setScratchingFinished(toolSessionID); } - // check if the time limit is exceeded - boolean isTimeLimitExceeded = toolSession.getTimeLimitLaunchedDate().getTime() - + scratchie.getTimeLimit() * 60000 < System.currentTimeMillis(); + // go through whole method again, with new settings + return "forward:start.do"; - // if the time limit is over and the leader hasn't submitted notebook or burning questions (thus - // non-leaders should wait) - show waitForLeaderFinish page - if (isTimeLimitExceeded && isWaitingForLeaderToSubmitNotebook) { - request.setAttribute(ScratchieConstants.ATTR_WAITING_MESSAGE_KEY, - "label.waiting.for.leader.submit.notebook"); - return "pages/learning/waitForLeaderTimeLimit"; - } + } else if (isScratchingFinished) { + // teacher gave extra time + toolSession.setScratchingFinished(false); + scratchieService.saveOrUpdateScratchieSession(toolSession); + // go through whole method again, with new settings + return "forward:start.do"; } + } - if (mode.isTeacher()) { - scratchieService.populateScratchieItemsWithMarks(scratchie, items, toolSessionID); - // get updated score from ScratchieSession - int score = toolSession.getMark(); - request.setAttribute(ScratchieConstants.ATTR_SCORE, score); - int percentage = (maxScore == 0) ? 0 : ((score * 100) / maxScore); - request.setAttribute(ScratchieConstants.ATTR_SCORE_PERCENTAGE, percentage); - } + // show learning.jsp page + if (mode.isTeacher()) { + scratchieService.populateScratchieItemsWithMarks(scratchie, items, toolSessionID); + // get updated score from ScratchieSession + int score = toolSession.getMark(); + request.setAttribute(ScratchieConstants.ATTR_SCORE, score); + int percentage = (maxScore == 0) ? 0 : ((score * 100) / maxScore); + request.setAttribute(ScratchieConstants.ATTR_SCORE_PERCENTAGE, percentage); + } - sessionMap.put(ScratchieConstants.ATTR_IS_SCRATCHING_FINISHED, isScratchingFinished); - // make non-leaders wait for notebook to be submitted, if required - sessionMap.put(ScratchieConstants.ATTR_IS_WAITING_FOR_LEADER_TO_SUBMIT_NOTEBOOK, - isWaitingForLeaderToSubmitNotebook); + sessionMap.put(ScratchieConstants.ATTR_IS_SCRATCHING_FINISHED, isScratchingFinished); + // make non-leaders wait for notebook to be submitted, if required + sessionMap.put(ScratchieConstants.ATTR_IS_WAITING_FOR_LEADER_TO_SUBMIT_NOTEBOOK, + isWaitingForLeaderToSubmitNotebook); - boolean questionEtherpadEnabled = scratchie.isQuestionEtherpadEnabled() - && StringUtils.isNotBlank(Configuration.get(ConfigurationKeys.ETHERPAD_API_KEY)); - request.setAttribute(ScratchieConstants.ATTR_IS_QUESTION_ETHERPAD_ENABLED, questionEtherpadEnabled); - if (questionEtherpadEnabled && scratchieService.isGroupedActivity(scratchie.getContentId())) { - // get all users from the group, even if they did not reach the Scratchie yet - // order them by first and last name - Collection allGroupUsers = scratchieService.getAllGroupUsers(toolSessionID).stream() - .sorted(Comparator.comparing(u -> u.getFirstName() + u.getLastName())) - .collect(Collectors.toList()); - request.setAttribute(ScratchieConstants.ATTR_ALL_GROUP_USERS, allGroupUsers); - } - - return "pages/learning/learning"; + boolean questionEtherpadEnabled = scratchie.isQuestionEtherpadEnabled() + && StringUtils.isNotBlank(Configuration.get(ConfigurationKeys.ETHERPAD_API_KEY)); + request.setAttribute(ScratchieConstants.ATTR_IS_QUESTION_ETHERPAD_ENABLED, questionEtherpadEnabled); + if (questionEtherpadEnabled && scratchieService.isGroupedActivity(scratchie.getContentId())) { + // get all users from the group, even if they did not reach the Scratchie yet + // order them by first and last name + Collection allGroupUsers = scratchieService.getAllGroupUsers(toolSessionID).stream() + .sorted(Comparator.comparing(u -> u.getFirstName() + u.getLastName())).collect(Collectors.toList()); + request.setAttribute(ScratchieConstants.ATTR_ALL_GROUP_USERS, allGroupUsers); } + + return "pages/learning/learning"; + } /** @@ -533,25 +534,6 @@ } /** - * Stores date when user has started activity with time limit. - */ - @RequestMapping("/launchTimeLimit") - @ResponseStatus(HttpStatus.OK) - private void launchTimeLimit(HttpServletRequest request) throws ScratchieApplicationException, SchedulerException { - SessionMap sessionMap = getSessionMap(request); - final Long toolSessionId = (Long) sessionMap.get(AttributeNames.PARAM_TOOL_SESSION_ID); - ScratchieSession toolSession = scratchieService.getScratchieSessionBySessionId(toolSessionId); - - ScratchieUser leader = getCurrentUser(toolSessionId); - // only leader is allowed to launch time limit - if (!toolSession.isUserGroupLeader(leader.getUid())) { - return; - } - - scratchieService.launchTimeLimit(toolSessionId); - } - - /** * Displays results page. When leader gets to this page, scratchingFinished column is set to true for all users. */ @RequestMapping("/showResults") @@ -574,6 +556,8 @@ if (toolSession.isUserGroupLeader(userUid) && !toolSession.isScratchingFinished()) { scratchieService.setScratchingFinished(toolSessionId); + // non-leaders need to go to results page + LearningWebsocketServer.getInstance().sendPageRefreshRequest(scratchie.getContentId(), toolSessionId); } // get updated score from ScratchieSession @@ -799,6 +783,8 @@ // in case of the leader we should let all other learners see Next Activity button if (toolSession.isUserGroupLeader(userUid) && !toolSession.isScratchingFinished()) { scratchieService.setScratchingFinished(toolSessionId); + // non-leaders need to go to results page + LearningWebsocketServer.getInstance().sendPageRefreshRequest(scratchie.getContentId(), toolSessionId); } return "pages/learning/notebook"; @@ -809,7 +795,7 @@ */ @RequestMapping("/submitReflection") public String submitReflection(@ModelAttribute("reflectionForm") ReflectionForm reflectionForm, - HttpServletRequest request) throws ScratchieApplicationException { + HttpServletRequest request) throws ScratchieApplicationException, IOException { final Integer userId = reflectionForm.getUserID(); final String entryText = reflectionForm.getEntryText(); @@ -832,6 +818,10 @@ } sessionMap.put(ScratchieConstants.ATTR_REFLECTION_ENTRY, entryText); + Scratchie scratchie = scratchieService.getScratchieBySessionId(sessionId); + // non-leaders need to go to results page + LearningWebsocketServer.getInstance().sendPageRefreshRequest(scratchie.getContentId(), sessionId); + String redirectURL = "redirect:showResults.do"; redirectURL = WebUtil.appendParameterToURL(redirectURL, ScratchieConstants.ATTR_SESSION_MAP_ID, sessionMap.getSessionID()); Index: lams_tool_scratchie/src/java/org/lamsfoundation/lams/tool/scratchie/web/controller/LearningWebsocketServer.java =================================================================== diff -u -rbcb806e82cb1d2f15b11791aeb5e8ff7335e0163 -r705c0f72b765849974bfa0d9f8b04797619e8da7 --- lams_tool_scratchie/src/java/org/lamsfoundation/lams/tool/scratchie/web/controller/LearningWebsocketServer.java (.../LearningWebsocketServer.java) (revision bcb806e82cb1d2f15b11791aeb5e8ff7335e0163) +++ lams_tool_scratchie/src/java/org/lamsfoundation/lams/tool/scratchie/web/controller/LearningWebsocketServer.java (.../LearningWebsocketServer.java) (revision 705c0f72b765849974bfa0d9f8b04797619e8da7) @@ -1,20 +1,16 @@ package org.lamsfoundation.lams.tool.scratchie.web.controller; import java.io.IOException; -import java.util.Calendar; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.Collection; -import java.util.GregorianCalendar; -import java.util.Iterator; +import java.util.Date; +import java.util.HashMap; import java.util.Map; -import java.util.Map.Entry; import java.util.Set; -import java.util.TimeZone; import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; -import javax.websocket.CloseReason; -import javax.websocket.CloseReason.CloseCodes; -import javax.websocket.OnClose; import javax.websocket.OnOpen; import javax.websocket.Session; import javax.websocket.server.ServerEndpoint; @@ -23,11 +19,13 @@ import org.lamsfoundation.lams.qb.model.QbQuestion; import org.lamsfoundation.lams.tool.scratchie.ScratchieConstants; import org.lamsfoundation.lams.tool.scratchie.dto.OptionDTO; +import org.lamsfoundation.lams.tool.scratchie.model.Scratchie; import org.lamsfoundation.lams.tool.scratchie.model.ScratchieItem; import org.lamsfoundation.lams.tool.scratchie.model.ScratchieSession; import org.lamsfoundation.lams.tool.scratchie.model.ScratchieUser; import org.lamsfoundation.lams.tool.scratchie.service.IScratchieService; -import org.lamsfoundation.lams.util.hibernate.HibernateSessionManager; +import org.lamsfoundation.lams.web.controller.AbstractTimeLimitWebsocketServer; +import org.lamsfoundation.lams.web.controller.AbstractTimeLimitWebsocketServer.EndpointConfigurator; import org.lamsfoundation.lams.web.session.SessionManager; import org.lamsfoundation.lams.web.util.AttributeNames; import org.springframework.web.context.WebApplicationContext; @@ -37,123 +35,131 @@ import com.fasterxml.jackson.databind.node.ObjectNode; /** - * Sends Scratchies actions to non-leaders. + * Sends Scratchies actions to non-leaders and time limit to leaders. * * @author Marcin Cieslak */ -@ServerEndpoint("/learningWebsocket") -public class LearningWebsocketServer { +@ServerEndpoint(value = "/learningWebsocket", configurator = EndpointConfigurator.class) +public class LearningWebsocketServer extends AbstractTimeLimitWebsocketServer { + private static final Logger log = Logger.getLogger(LearningWebsocketServer.class); + + private IScratchieService scratchieService; + + // maps toolContentId -> toolSessionId -> itemUid -> optionUid -> isCorrect + private final Map>>> scratchCache = new ConcurrentHashMap<>(); + + public LearningWebsocketServer() { + if (scratchieService == null) { + WebApplicationContext wac = WebApplicationContextUtils + .getRequiredWebApplicationContext(SessionManager.getServletContext()); + scratchieService = (IScratchieService) wac.getBean(ScratchieConstants.SCRATCHIE_SERVICE); + } + } + + @Override + protected Logger getLog() { + return LearningWebsocketServer.log; + } + + @Override + @OnOpen + public void registerUser(Session websocket) throws IOException { + super.registerUser(websocket); + + Long toolContentId = Long + .valueOf(websocket.getRequestParameterMap().get(AttributeNames.PARAM_TOOL_CONTENT_ID).get(0)); + Long toolSessionId = Long + .valueOf(websocket.getRequestParameterMap().get(AttributeNames.PARAM_TOOL_SESSION_ID).get(0)); + websocket.getUserProperties().put(AttributeNames.PARAM_TOOL_SESSION_ID, toolSessionId); + + Map>> sessionCache = scratchCache.get(toolContentId); + Map> questionCache = null; + if (sessionCache == null) { + sessionCache = new ConcurrentHashMap<>(); + scratchCache.put(toolContentId, sessionCache); + } else { + questionCache = sessionCache.get(toolSessionId); + } + + if (questionCache == null) { + questionCache = new HashMap<>(); + sessionCache.put(toolSessionId, questionCache); + } + } + /** - * A singleton which updates Learners with reports and votes. + * Fetches or creates a singleton of this websocket server. */ - private static class SendWorker extends Thread { - private boolean stopFlag = false; - // how ofter the thread runs - private static final long CHECK_INTERVAL = 3000; + public static LearningWebsocketServer getInstance() { + LearningWebsocketServer result = (LearningWebsocketServer) AbstractTimeLimitWebsocketServer + .getInstance(LearningWebsocketServer.class.getName()); + if (result == null) { + result = new LearningWebsocketServer(); + AbstractTimeLimitWebsocketServer.registerInstance(result); + } + return result; + } - @Override - public void run() { - while (!stopFlag) { - try { - // websocket communication bypasses standard HTTP filters, so Hibernate session needs to be initialised manually - HibernateSessionManager.openSession(); + @Override + protected TimeCache getExistingTimeSettings(long toolContentId, Collection userIds) { + Scratchie scratchie = scratchieService.getScratchieByContentId(toolContentId); + TimeCache existingTimeSettings = new TimeCache(); - Iterator>> entryIterator = LearningWebsocketServer.websockets.entrySet() - .iterator(); - // go through activities and update registered learners with reports and vote count - while (entryIterator.hasNext()) { - Entry> entry = entryIterator.next(); - Long toolSessionId = entry.getKey(); + existingTimeSettings.absoluteTimeLimit = null; + existingTimeSettings.relativeTimeLimit = scratchie.getTimeLimit() * 60; + existingTimeSettings.timeLimitAdjustment = new HashMap<>(); - Set sessionWebsockets = entry.getValue(); - ScratchieSession toolSession = LearningWebsocketServer.getScratchieService() - .getScratchieSessionBySessionId(toolSessionId); - // if all learners left the activity or session is missing, remove the obsolete mapping - if (sessionWebsockets.isEmpty() || toolSession == null) { - entryIterator.remove(); - LearningWebsocketServer.cache.remove(toolSessionId); - continue; - } + Map sessionLaunchDates = new HashMap<>(); + for (Integer userId : userIds) { + ScratchieUser user = scratchieService.getUserByUserIDAndContentID(userId.longValue(), toolContentId); + if (user == null) { + continue; + } - boolean timeLimitUp = false; - boolean scratchingFinished = toolSession.isScratchingFinished(); - // is Scratchie time limited? - if (toolSession.getTimeLimitLaunchedDate() != null) { - // missing whole cache is a marker that we already checked time limit and it is up - if (LearningWebsocketServer.cache.get(toolSessionId) == null) { - timeLimitUp = true; - } else { - // calculate whether time limit is up - Calendar currentTime = new GregorianCalendar(TimeZone.getDefault()); - Calendar timeLimitFinishDate = new GregorianCalendar(TimeZone.getDefault()); - timeLimitFinishDate.setTime(toolSession.getTimeLimitLaunchedDate()); - timeLimitFinishDate.add(Calendar.MINUTE, toolSession.getScratchie().getTimeLimit()); - //adding 5 extra seconds to let leader auto-submit results and store them in DB - timeLimitFinishDate.add(Calendar.SECOND, 5); - if (timeLimitFinishDate.compareTo(currentTime) <= 0) { - // time is up - if (!scratchingFinished) { - // Leader did not finish scratching yet? Pity. We do it for him - LearningWebsocketServer.getScratchieService() - .setScratchingFinished(toolSessionId); - scratchingFinished = true; - } - // mark time limit as up with this trick - LearningWebsocketServer.cache.remove(toolSessionId); - timeLimitUp = true; - } - } - } + ScratchieSession session = user.getSession(); + LocalDateTime sessionLaunchDate = null; - boolean isWaitingForLeaderToSubmit = LearningWebsocketServer.getScratchieService() - .isWaitingForLeaderToSubmitNotebook(toolSession); - if (timeLimitUp) { - // time limit is up - if (isWaitingForLeaderToSubmit) { - // if Leader did not finish burning questions or notebook, push non-leaders to wait page - LearningWebsocketServer.sendPageRefreshRequest(toolSessionId); - } else { - // if Leader finished everything, non-leaders see Finish button - LearningWebsocketServer.sendCloseRequest(toolSessionId); - } - } else if (scratchingFinished && !isWaitingForLeaderToSubmit) { - // time limit not set or not up yet, but everything is finished - // show non-leaders Finish button - LearningWebsocketServer.sendCloseRequest(toolSessionId); - } else { - // regular send of scratched items - SendWorker.send(toolSessionId); - } - } - } catch (IllegalStateException e) { - // do nothing as server is probably shutting down and we could not obtain Hibernate session - } catch (Exception e) { - //TODO remove this once NullPointerExceptions do not show anymore in logs - e.printStackTrace(); - // error caught, but carry on - log.error("Error in Scratchie worker thread", e); - } finally { - try { - HibernateSessionManager.closeSession(); - Thread.sleep(SendWorker.CHECK_INTERVAL); - } catch (IllegalStateException | InterruptedException e) { - stopFlag = true; - LearningWebsocketServer.log.warn("Stopping Scratchie worker thread"); - } + if (sessionLaunchDates.containsKey(session.getUid())) { + sessionLaunchDate = sessionLaunchDates.get(session.getUid()); + } else { + Date launchDate = session.getTimeLimitLaunchedDate(); + if (launchDate != null) { + sessionLaunchDate = launchDate.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); } + + sessionLaunchDates.put(session.getUid(), sessionLaunchDate); } + + if (sessionLaunchDate != null) { + existingTimeSettings.timeLimitLaunchedDate.put(userId, sessionLaunchDate); + } } - /** - * Feeds websockets with scratched options. - */ - private static void send(Long toolSessionId) throws IOException { + return existingTimeSettings; + } + + @Override + protected LocalDateTime launchUserTimeLimit(long toolContentId, int userId) { + return scratchieService.launchTimeLimit(toolContentId, userId); + } + + @Override + protected boolean processActivity(long toolContentId, Collection websockets) throws IOException { + boolean result = super.processActivity(toolContentId, websockets); + if (!result) { + // there are no more learners on the learner screen, so remove cached answers + scratchCache.remove(toolContentId); + return false; + } + + Map>> sessionScratchCache = scratchCache.get(toolContentId); + + for (Long toolSessionId : sessionScratchCache.keySet()) { ObjectNode responseJSON = JsonNodeFactory.instance.objectNode(); - Collection items = LearningWebsocketServer.getScratchieService() - .getItemsWithIndicatedScratches(toolSessionId); - Map> sessionCache = LearningWebsocketServer.cache.get(toolSessionId); + Collection items = scratchieService.getItemsWithIndicatedScratches(toolSessionId); + Map> questionCache = sessionScratchCache.get(toolSessionId); for (ScratchieItem item : items) { Long itemUid = item.getUid(); // do not init variables below until it's really needed @@ -163,10 +169,10 @@ if (optionDto.isScratched()) { // answer is scratched, check if it is present in cache if (itemCache == null) { - itemCache = sessionCache.get(itemUid); + itemCache = questionCache.get(itemUid); if (itemCache == null) { itemCache = new TreeMap<>(); - sessionCache.put(itemUid, itemCache); + questionCache.put(itemUid, itemCache); } } @@ -197,110 +203,32 @@ } // are there any updates to send? - if (responseJSON.size() == 0) { - return; + if (responseJSON.size() > 0) { + String response = responseJSON.toString(); + for (Session websocket : websockets) { + long userSessionId = (Long) websocket.getUserProperties().get(AttributeNames.PARAM_TOOL_SESSION_ID); + if (toolSessionId.equals(userSessionId)) { + websocket.getBasicRemote().sendText(response); + } + } } - - String response = responseJSON.toString(); - Set sessionWebsockets = LearningWebsocketServer.websockets.get(toolSessionId); - for (Session websocket : sessionWebsockets) { - websocket.getBasicRemote().sendText(response); - } } - } - private static final Logger log = Logger.getLogger(LearningWebsocketServer.class); - - private static IScratchieService scratchieService; - - private static final SendWorker sendWorker = new SendWorker(); - // maps toolSessionId -> itemUid -> optionUid -> isCorrect - private static final Map>> cache = new ConcurrentHashMap<>(); - private static final Map> websockets = new ConcurrentHashMap<>(); - - static { - // run the singleton thread - LearningWebsocketServer.sendWorker.start(); + return true; } - /** - * Registeres the Learner for processing by SendWorker. - */ - @OnOpen - public void registerUser(Session websocket) throws IOException { - Long toolSessionId = Long - .valueOf(websocket.getRequestParameterMap().get(AttributeNames.PARAM_TOOL_SESSION_ID).get(0)); - String login = websocket.getUserPrincipal().getName(); - ScratchieUser user = LearningWebsocketServer.getScratchieService().getUserByLoginAndSessionId(login, - toolSessionId); - if (user == null) { - throw new SecurityException("User \"" + login - + "\" is not a participant in Scratchie activity with tool session ID " + toolSessionId); - } - - Set sessionWebsockets = LearningWebsocketServer.websockets.get(toolSessionId); - if (sessionWebsockets == null) { - sessionWebsockets = ConcurrentHashMap.newKeySet(); - LearningWebsocketServer.websockets.put(toolSessionId, sessionWebsockets); - - Map> sessionCache = new TreeMap<>(); - LearningWebsocketServer.cache.put(toolSessionId, sessionCache); - } - sessionWebsockets.add(websocket); - - if (log.isDebugEnabled()) { - log.debug("User " + login + " entered Scratchie with toolSessionId: " + toolSessionId); - } + public static Long getSecondsLeft(long toolContentId, int userId) { + LearningWebsocketServer instance = LearningWebsocketServer.getInstance(); + // get time limit launch date only if it exists; only leader launches the timer + return AbstractTimeLimitWebsocketServer.getSecondsLeft(instance, toolContentId, userId, false); } /** - * When user leaves the activity. - */ - @OnClose - public void unregisterUser(Session websocket, CloseReason reason) { - Long toolSessionId = Long - .valueOf(websocket.getRequestParameterMap().get(AttributeNames.PARAM_TOOL_SESSION_ID).get(0)); - LearningWebsocketServer.websockets.get(toolSessionId).remove(websocket); - - if (log.isDebugEnabled()) { - // If there was something wrong with the connection, put it into logs. - log.debug("User " + websocket.getUserPrincipal().getName() + " left Scratchie with Tool Session ID: " - + toolSessionId - + (!(reason.getCloseCode().equals(CloseCodes.GOING_AWAY) - || reason.getCloseCode().equals(CloseCodes.NORMAL_CLOSURE)) - ? ". Abnormal close. Code: " + reason.getCloseCode() + ". Reason: " - + reason.getReasonPhrase() - : "")); - } - } - - /** - * The leader finished scratching and also . Non-leaders will have - * Finish button displayed. - */ - public static void sendCloseRequest(Long toolSessionId) throws IOException { - Set sessionWebsockets = LearningWebsocketServer.websockets.get(toolSessionId); - if (sessionWebsockets == null) { - return; - } - - ObjectNode responseJSON = JsonNodeFactory.instance.objectNode(); - responseJSON.put("close", true); - String response = responseJSON.toString(); - - for (Session websocket : sessionWebsockets) { - if (websocket.isOpen()) { - websocket.getBasicRemote().sendText(response); - } - } - } - - /** * The time limit is expired but leader hasn't submitted required notebook/burning questions yet. Non-leaders * will need to refresh the page in order to stop showing them questions page. */ - public static void sendPageRefreshRequest(Long toolSessionId) throws IOException { - Set sessionWebsockets = LearningWebsocketServer.websockets.get(toolSessionId); + public void sendPageRefreshRequest(long toolContentId, long toolSessionId) throws IOException { + Set sessionWebsockets = websockets.get(toolContentId); if (sessionWebsockets == null) { return; } @@ -310,18 +238,10 @@ String response = responseJSON.toString(); for (Session websocket : sessionWebsockets) { - if (websocket.isOpen()) { + long userSessionId = (Long) websocket.getUserProperties().get(AttributeNames.PARAM_TOOL_SESSION_ID); + if (toolSessionId == userSessionId) { websocket.getBasicRemote().sendText(response); } } } - - private static IScratchieService getScratchieService() { - if (scratchieService == null) { - WebApplicationContext wac = WebApplicationContextUtils - .getRequiredWebApplicationContext(SessionManager.getServletContext()); - scratchieService = (IScratchieService) wac.getBean(ScratchieConstants.SCRATCHIE_SERVICE); - } - return scratchieService; - } } \ No newline at end of file Index: lams_tool_scratchie/web/pages/learning/learning.jsp =================================================================== diff -u -r281e19ba98aa6bf38fc1f25507a9098304d668fb -r705c0f72b765849974bfa0d9f8b04797619e8da7 --- lams_tool_scratchie/web/pages/learning/learning.jsp (.../learning.jsp) (revision 281e19ba98aa6bf38fc1f25507a9098304d668fb) +++ lams_tool_scratchie/web/pages/learning/learning.jsp (.../learning.jsp) (revision 705c0f72b765849974bfa0d9f8b04797619e8da7) @@ -1,6 +1,7 @@ <%@ include file="/common/taglibs.jsp"%> + <%-- param has higher level for request attribute --%> @@ -10,6 +11,9 @@ + + + @@ -23,7 +27,19 @@ - + @@ -83,6 +99,11 @@ } }); + // hide Finish button for non-leaders until leader finishes + if (${hideFinishButton}) { + $("#finishButton").hide(); + } + <%-- Connect to command websocket only if it is learner UI --%> // command websocket stuff for refreshing @@ -272,46 +293,11 @@ (((prevHash << 5) - prevHash) + currVal.charCodeAt(0))|0, 0); } - //boolean to indicate whether ok dialog is still ON so that autosave can't be run - var isWaitingForConfirmation = ${isTimeLimitEnabled && isTimeLimitNotLaunched}; - //time limit feature - - $(document).ready(function(){ - - //show timelimit-start-dialog in order to start countdown - if (${isTimeLimitNotLaunched}) { - - //show confirmation dialog - $.blockUI({ - message: $('#timelimit-start-dialog'), - css: { width: '325px', height: '120px'}, - overlayCSS: { opacity: '.98'} - }); - - //once OK button pressed start countdown - $('#timelimit-start-ok').click(function() { - - //store date when user has started activity with time limit - $.ajax({ - async: true, - url: '', - data: 'sessionMapID=${sessionMapID}', - type: 'post' - }); - - $.unblockUI(); - displayCountdown(); - isWaitingForConfirmation = false; - }); - - } else { - displayCountdown(); - } - - }); + + // time limit feature - function displayCountdown(){ + function displayCountdown(secondsLeft){ var countdown = '
' $.blockUI({ message: countdown, @@ -329,30 +315,123 @@ }); $('#countdown').countdown({ - until: '+${secondsLeft}S', + until: '+' + secondsLeft +'S', format: 'hMS', compact: true, + alwaysExpire : true, onTick: function(periods) { - //check for 30 seconds - if ((periods[4] == 0) && (periods[5] == 0) && (periods[6] <= 30)) { - $('#countdown').css('color', '#FF3333'); - } + // check for 30 seconds or less and display timer in red + var secondsLeft = $.countdown.periodsToSeconds(periods); + if (secondsLeft <= 30) { + $(this).addClass('countdown-timeout'); + } else { + $(this).removeClass('countdown-timeout'); + } }, onExpiry: function(periods) { - $.blockUI({ message: '

' }); + $.blockUI({ message: '

' }); - setTimeout( - function() { - finish(true); - }, - 4000 - ); + setTimeout(function() { + if (${isUserLeader}) { + finish(true); + } else { + location.reload(); + } + }, 4000); }, description: "
" }); } -
+ + //init the connection with server using server URL but with different protocol + var scratchieWebsocketInitTime = Date.now(), + scratchieWebsocket = new WebSocket(''.replace('http', 'ws') + + 'learningWebsocket?toolSessionID=' + ${toolSessionID} + '&toolContentID=' + ${scratchie.contentId}), + scratchieWebsocketPingTimeout = null, + scratchieWebsocketPingFunc = null; + + scratchieWebsocket.onclose = function(e) { + // react only on abnormal close + if (e.code === 1006 && + Date.now() - scratchieWebsocketInitTime > 1000) { + location.reload(); + } + }; + + scratchieWebsocketPingFunc = function(skipPing){ + if (scratchieWebsocket.readyState == scratchieWebsocket.CLOSING + || scratchieWebsocket.readyState == scratchieWebsocket.CLOSED){ + return; + } + + // check and ping every 3 minutes + scratchieWebsocketPingTimeout = setTimeout(scratchieWebsocketPingFunc, 3*60*1000); + // initial set up does not send ping + if (!skipPing) { + scratchieWebsocket.send("ping"); + } + }; + + // set up timer for the first time + scratchieWebsocketPingFunc(true); + + // run when the server pushes new reports and vote statistics + scratchieWebsocket.onmessage = function(e) { + // create JSON object + var input = JSON.parse(e.data); + if (input.pageRefresh) { + location.reload(); + return; + } + + if (input.clearTimer == true) { + // teacher stopped the timer, destroy it + $('#countdown').countdown('destroy').remove(); + } else if (input.secondsLeft){ + // teacher updated the timer + var secondsLeft = +input.secondsLeft, + counterInitialised = $('#countdown').length > 0; + + if (counterInitialised) { + // just set the new time + $('#countdown').countdown('option', 'until', secondsLeft + 'S'); + } else if (secondsLeft){ + if (${isScratchingFinished}) { + // teacher gave extra time, reload to enable scratching again + location.reload(); + return; + } + // initialise the timer + displayCountdown(secondsLeft); + } + } else if (${not isUserLeader}){ + // reflect the leader's choices + $.each(input, function(itemUid, options) { + $.each(options, function(optionUid, optionProperties){ + + if (optionProperties.isVSA) { + var answer = optionUid; + optionUid = hashCode(optionUid); + + //check if such image exists, create it otherwise + if ($('#image-' + itemUid + '-' + optionUid).length == 0) { + paintNewVsaAnswer(eval(itemUid), answer); + } + } + + scratchImage(itemUid, optionUid, optionProperties.isCorrect); + }); + }); + } + + // reset ping timer + clearTimeout(scratchieWebsocketPingTimeout); + scratchieWebsocketPingFunc(true); + }; +
+ + //autosave feature var autosaveInterval = "60000"; // 60 seconds interval Index: lams_tool_scratchie/web/pages/learning/questionlist.jsp =================================================================== diff -u -r0ac9fd6fe9f8c417393174e0ad1e68ef09ab33c7 -r705c0f72b765849974bfa0d9f8b04797619e8da7 --- lams_tool_scratchie/web/pages/learning/questionlist.jsp (.../questionlist.jsp) (revision 0ac9fd6fe9f8c417393174e0ad1e68ef09ab33c7) +++ lams_tool_scratchie/web/pages/learning/questionlist.jsp (.../questionlist.jsp) (revision 705c0f72b765849974bfa0d9f8b04797619e8da7) @@ -2,107 +2,11 @@ -<%-- param has higher level for request attribute --%> - - - - - - - - - - - - - - -
<%@ include file="scratchies.jsp"%>