/** * OLAT - Online Learning and Training<br> * http://www.olat.org * <p> * Licensed under the Apache License, Version 2.0 (the "License"); <br> * you may not use this file except in compliance with the License.<br> * You may obtain a copy of the License at * <p> * http://www.apache.org/licenses/LICENSE-2.0 * <p> * Unless required by applicable law or agreed to in writing,<br> * software distributed under the License is distributed on an "AS IS" BASIS, <br> * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br> * See the License for the specific language governing permissions and <br> * limitations under the License. * <p> * Copyright (c) since 2004 at Multimedia- & E-Learning Services (MELS),<br> * University of Zurich, Switzerland. * <hr> * <a href="http://www.openolat.org"> * OpenOLAT - Online Learning and Training</a><br> * This file has been modified by the OpenOLAT community. Changes are licensed * under the Apache 2.0 license as the original file. */ package org.olat.course.nodes.basiclti; import java.net.MalformedURLException; import java.net.URL; import java.util.Enumeration; import java.util.Map; import org.imsglobal.basiclti.BasicLTIUtil; import org.olat.core.CoreSpringFactory; import org.olat.core.dispatcher.mapper.Mapper; import org.olat.core.gui.UserRequest; import org.olat.core.gui.components.Component; import org.olat.core.gui.components.link.Link; import org.olat.core.gui.components.link.LinkFactory; import org.olat.core.gui.components.panel.SimpleStackedPanel; import org.olat.core.gui.components.panel.StackedPanel; import org.olat.core.gui.components.velocity.VelocityContainer; import org.olat.core.gui.control.ChiefController; import org.olat.core.gui.control.Event; import org.olat.core.gui.control.ScreenMode.Mode; import org.olat.core.gui.control.WindowControl; import org.olat.core.gui.control.controller.BasicController; import org.olat.core.helpers.Settings; import org.olat.core.id.Roles; import org.olat.core.id.User; import org.olat.core.id.UserConstants; import org.olat.core.util.Encoder; import org.olat.core.util.SortedProperties; import org.olat.core.util.StringHelper; import org.olat.core.util.Util; import org.olat.course.groupsandrights.CourseGroupManager; import org.olat.course.highscore.ui.HighScoreRunController; import org.olat.course.nodes.BasicLTICourseNode; import org.olat.course.nodes.CourseNode; import org.olat.course.nodes.MSCourseNode; import org.olat.course.properties.CoursePropertyManager; import org.olat.course.run.environment.CourseEnvironment; import org.olat.course.run.scoring.ScoreEvaluation; import org.olat.course.run.userview.UserCourseEnvironment; import org.olat.ims.lti.LTIContext; import org.olat.ims.lti.LTIDisplayOptions; import org.olat.ims.lti.LTIManager; import org.olat.ims.lti.ui.PostDataMapper; import org.olat.ims.lti.ui.TalkBackMapper; import org.olat.modules.ModuleConfiguration; import org.olat.properties.Property; import org.olat.resource.OLATResource; /** * Description:<br> * is the controller for displaying contents in an iframe served by Basic LTI * @author guido * @author Charles Severance * */ public class LTIRunController extends BasicController { private static final String PROP_NAME_DATA_EXCHANGE_ACCEPTED = "LtiDataExchageAccepted"; private Link startButton; private final StackedPanel mainPanel; private VelocityContainer run; private VelocityContainer startPage; private BasicLTICourseNode courseNode; private ModuleConfiguration config; private final CourseEnvironment courseEnv; private UserCourseEnvironment userCourseEnv; private SortedProperties userData = new SortedProperties(); private SortedProperties customUserData = new SortedProperties(); private Link acceptLink; private Link back; private boolean fullScreen; private ChiefController thebaseChief; private final Roles roles; private final LTIManager ltiManager; private final LTIDisplayOptions display; public LTIRunController(WindowControl wControl, ModuleConfiguration config, UserRequest ureq, BasicLTICourseNode ltCourseNode, CourseEnvironment courseEnv) { super(ureq, wControl, Util.createPackageTranslator(CourseNode.class, ureq.getLocale())); this.courseNode = ltCourseNode; this.config = config; this.roles = ureq.getUserSession().getRoles(); this.courseEnv = courseEnv; display = LTIDisplayOptions.iframe; ltiManager = CoreSpringFactory.getImpl(LTIManager.class); run = createVelocityContainer("run"); // push title and learning objectives, only visible on intro page run.contextPut("menuTitle", courseNode.getShortTitle()); run.contextPut("displayTitle", courseNode.getLongTitle()); if (courseNode.getModuleConfiguration().getBooleanSafe(MSCourseNode.CONFIG_KEY_HAS_SCORE_FIELD,false)){ HighScoreRunController highScoreCtr = new HighScoreRunController(ureq, wControl, userCourseEnv, courseNode); if (highScoreCtr.isViewHighscore()) { Component highScoreComponent = highScoreCtr.getInitialComponent(); run.put("highScore", highScoreComponent); } } doBasicLTI(ureq, run); mainPanel = putInitialPanel(run); } /** * Constructor for tunneling run controller * * @param wControl * @param config The module configuration * @param ureq The user request * @param ltCourseNode The current course node * @param cenv the course environment */ public LTIRunController(WindowControl wControl, ModuleConfiguration config, UserRequest ureq, BasicLTICourseNode ltCourseNode, UserCourseEnvironment userCourseEnv) { super(ureq, wControl, Util.createPackageTranslator(CourseNode.class, ureq.getLocale())); this.courseNode = ltCourseNode; this.config = config; this.userCourseEnv = userCourseEnv; this.roles = ureq.getUserSession().getRoles(); this.courseEnv = userCourseEnv.getCourseEnvironment(); this.ltiManager = CoreSpringFactory.getImpl(LTIManager.class); String displayStr = config.getStringValue(BasicLTICourseNode.CONFIG_DISPLAY, "iframe"); display = LTIDisplayOptions.valueOfOrDefault(displayStr); mainPanel = new SimpleStackedPanel("ltiContainer"); putInitialPanel(mainPanel); // only run directly when user as already accepted to data exchange or no data has to be exchanged createExchangeDataProperties(); String dataExchangeHash = createHashFromExchangeDataProperties(); if (dataExchangeHash == null || checkHasDataExchangeAccepted(dataExchangeHash)) { doRun(ureq); } else { doAskDataExchange(); } } /** * Helper method to check if user has already accepted. this info is stored * in a user property, the accepted values are stored as an MD5 hash (save * space, privacy) * * @param hash * MD5 hash with all user data * @return true: user has already accepted for this hash; false: user has * not yet accepted or for other values */ private boolean checkHasDataExchangeAccepted(String hash) { boolean dataAccepted = false; CoursePropertyManager propMgr = this.userCourseEnv.getCourseEnvironment().getCoursePropertyManager(); Property prop = propMgr.findCourseNodeProperty(this.courseNode, getIdentity(), null, PROP_NAME_DATA_EXCHANGE_ACCEPTED); if (prop != null) { // compare if value in property is the same as calculated today. If not, user as to accept again String storedHash = prop.getStringValue(); if (storedHash != null && hash != null && storedHash.equals(hash)) { dataAccepted = true; } else { // remove property, not valid anymore propMgr.deleteProperty(prop); } } return dataAccepted; } /** * Helper to initialize the ask-for-data-exchange screen */ private void doAskDataExchange() { VelocityContainer acceptPage = createVelocityContainer("accept"); acceptPage.contextPut("userData", userData); acceptPage.contextPut("customUserData", customUserData); acceptLink = LinkFactory.createButton("accept", acceptPage, this); acceptLink.setPrimary(true); mainPanel.setContent(acceptPage); } /** * Helper to save the user accepted data exchange */ private void storeDataExchangeAcceptance() { CoursePropertyManager propMgr = this.userCourseEnv.getCourseEnvironment().getCoursePropertyManager(); String hash = createHashFromExchangeDataProperties(); Property prop = propMgr.createCourseNodePropertyInstance(this.courseNode, getIdentity(), null, PROP_NAME_DATA_EXCHANGE_ACCEPTED, null, null, hash, null); propMgr.saveProperty(prop); } /** * Helper to read all user data that is exchanged with LTI tool and saves it * to the userData and customUserData properties fields */ private void createExchangeDataProperties() { final User user = getIdentity().getUser(); //user data if (config.getBooleanSafe(LTIConfigForm.CONFIG_KEY_SENDNAME, false)) { String lastName = user.getProperty(UserConstants.LASTNAME, getLocale()); if(StringHelper.containsNonWhitespace(lastName)) { userData.put("lastName", lastName); } String firstName = user.getProperty(UserConstants.FIRSTNAME, getLocale()); if(StringHelper.containsNonWhitespace(firstName)) { userData.put("firstName", firstName); } } if (config.getBooleanSafe(LTIConfigForm.CONFIG_KEY_SENDEMAIL, false)) { String email = user.getProperty(UserConstants.EMAIL, getLocale()); if(StringHelper.containsNonWhitespace(email)) { userData.put("email", email); } } // customUserData String custom = (String)config.get(LTIConfigForm.CONFIG_KEY_CUSTOM); if (StringHelper.containsNonWhitespace(custom)) { String[] params = custom.split("[\n;]"); for (int i = 0; i < params.length; i++) { String param = params[i]; if (!StringHelper.containsNonWhitespace(param)) { continue; } int pos = param.indexOf("="); if (pos < 1 || pos + 1 > param.length()) { continue; } String key = BasicLTIUtil.mapKeyName(param.substring(0, pos)); if(!StringHelper.containsNonWhitespace(key)) { continue; } String value = param.substring(pos + 1).trim(); if(value.length() < 1) { continue; } if(value.startsWith(LTIManager.USER_PROPS_PREFIX)) { String userProp = value.substring(LTIManager.USER_PROPS_PREFIX.length(), value.length()); value = user.getProperty(userProp, null); if (value!= null) { customUserData.put(userProp, value); } } } } } /** * Helper to create an MD5 hash from the exchanged user properties. * @return */ private String createHashFromExchangeDataProperties() { String data = ""; String hash = null; if (userData != null && userData.size() > 0) { Enumeration<Object> keys = userData.keys(); while (keys.hasMoreElements()) { String key = (String) keys.nextElement(); data += userData.getProperty(key); } } if (customUserData != null && customUserData.size() > 0) { Enumeration<Object> keys = customUserData.keys(); while (keys.hasMoreElements()) { String key = (String) keys.nextElement(); data += customUserData.getProperty(key); } } if (data.length() > 0) { hash = Encoder.md5hash(data); } if (isLogDebugEnabled()) { logDebug("Create accept hash::" + hash + " for data::" + data, null); } return hash; } /** * Helper to initialize the LTI run view after user has accepted data exchange. * @param ureq */ private void doRun(UserRequest ureq) { if (display == LTIDisplayOptions.window) { // Use other container for popup opening. Rest of code is the same run = createVelocityContainer("runPopup"); } else if (display == LTIDisplayOptions.fullscreen) { run = createVelocityContainer("run"); back = LinkFactory.createLinkBack(run, this); run.put("back", back); } else { run = createVelocityContainer("run"); } // push title and learning objectives, only visible on intro page run.contextPut("menuTitle", courseNode.getShortTitle()); run.contextPut("displayTitle", courseNode.getLongTitle()); startPage = createVelocityContainer("overview"); startPage.contextPut("menuTitle", courseNode.getShortTitle()); startPage.contextPut("displayTitle", courseNode.getLongTitle()); if (courseNode.getModuleConfiguration().getBooleanSafe(MSCourseNode.CONFIG_KEY_HAS_SCORE_FIELD,false)){ HighScoreRunController highScoreCtr = new HighScoreRunController(ureq, getWindowControl(), userCourseEnv, courseNode); if (highScoreCtr.isViewHighscore()) { Component highScoreComponent = highScoreCtr.getInitialComponent(); startPage.put("highScore", highScoreComponent); } } startButton = LinkFactory.createButton("start", startPage, this); startButton.setPrimary(true); Boolean assessable = config.getBooleanEntry(BasicLTICourseNode.CONFIG_KEY_HAS_SCORE_FIELD); if(assessable != null && assessable.booleanValue()) { startPage.contextPut("isassessable", assessable); Integer attempts = courseNode.getUserAttempts(userCourseEnv); startPage.contextPut("attempts", attempts); ScoreEvaluation eval = courseNode.getUserScoreEvaluation(userCourseEnv); Float cutValue = config.getFloatEntry(BasicLTICourseNode.CONFIG_KEY_PASSED_CUT_VALUE); if(cutValue != null) { startPage.contextPut("hasPassedValue", Boolean.TRUE); startPage.contextPut("passed", eval.getPassed()); } startPage.contextPut("score", eval.getScore()); startPage.contextPut("hasScore", Boolean.TRUE); boolean resultsVisible = eval.getUserVisible() == null || eval.getUserVisible().booleanValue(); startPage.contextPut("resultsVisible", resultsVisible); mainPanel.setContent(startPage); } else if(display == LTIDisplayOptions.window) { mainPanel.setContent(startPage); } else { openBasicLTIContent(ureq); } } private void openBasicLTIContent(UserRequest ureq) { // container is "run", "runFullscreen" or "runPopup" depending in configuration doBasicLTI(ureq, run); if (display == LTIDisplayOptions.fullscreen) { ChiefController cc = getWindowControl().getWindowBackOffice().getChiefController(); if (cc != null) { thebaseChief = cc; thebaseChief.getScreenMode().setMode(Mode.full); } fullScreen = true; getWindowControl().pushToMainArea(run); } else { mainPanel.setContent(run); } } private void closeBasicLTI() { if (fullScreen && thebaseChief != null) { getWindowControl().pop(); thebaseChief.getScreenMode().setMode(Mode.standard); } mainPanel.setContent(startPage); } @Override public void event(UserRequest ureq, Component source, Event event) { if(source == startButton) { courseNode.incrementUserAttempts(userCourseEnv); openBasicLTIContent(ureq); } else if (source == acceptLink) { storeDataExchangeAcceptance(); doRun(ureq); } else if(source == back) { closeBasicLTI(); } } private String getUrl() { // put url in template to show content on extern page URL url = null; try { url = new URL((String)config.get(LTIConfigForm.CONFIGKEY_PROTO), (String) config.get(LTIConfigForm.CONFIGKEY_HOST), ((Integer) config .get(LTIConfigForm.CONFIGKEY_PORT)).intValue(), (String) config.get(LTIConfigForm.CONFIGKEY_URI)); } catch (MalformedURLException e) { // this should not happen since the url was already validated in edit mode return null; } StringBuilder querySb = new StringBuilder(128); querySb.append(url.toString()); // since the url only includes the path, but not the query (?...), append // it here, if any String query = (String) config.get(LTIConfigForm.CONFIGKEY_QUERY); if (query != null) { querySb.append("?"); querySb.append(query); } return querySb.toString(); } private void doBasicLTI(UserRequest ureq, VelocityContainer container) { String url = getUrl(); container.contextPut("url", url == null ? "" : url); String oauth_consumer_key = (String) config.get(LTIConfigForm.CONFIGKEY_KEY); String oauth_secret = (String) config.get(LTIConfigForm.CONFIGKEY_PASS); String debug = (String) config.get(LTIConfigForm.CONFIG_KEY_DEBUG); String serverUri = Settings.createServerURI(); String sourcedId = courseEnv.getCourseResourceableId() + "_" + courseNode.getIdent() + "_" + getIdentity().getKey(); container.contextPut("sourcedId", sourcedId); OLATResource courseResource = courseEnv.getCourseGroupManager().getCourseResource(); Mapper talkbackMapper = new TalkBackMapper(getLocale(), getWindowControl().getWindowBackOffice().getWindow().getGuiTheme().getBaseURI()); String backMapperUrl = registerCacheableMapper(ureq, sourcedId + "_talkback", talkbackMapper); String backMapperUri = serverUri + backMapperUrl + "/"; Mapper outcomeMapper = new CourseNodeOutcomeMapper(getIdentity(), courseResource, courseNode.getIdent(), oauth_consumer_key, oauth_secret, sourcedId); String outcomeMapperUrl = registerCacheableMapper(ureq, sourcedId, outcomeMapper, LTIManager.EXPIRATION_TIME); String outcomeMapperUri = serverUri + outcomeMapperUrl + "/"; boolean sendname = config.getBooleanSafe(LTIConfigForm.CONFIG_KEY_SENDNAME, false); boolean sendmail = config.getBooleanSafe(LTIConfigForm.CONFIG_KEY_SENDEMAIL, false); String ltiRoles = getLTIRoles(); String target = config.getStringValue(BasicLTICourseNode.CONFIG_DISPLAY); String width = config.getStringValue(BasicLTICourseNode.CONFIG_WIDTH); String height = config.getStringValue(BasicLTICourseNode.CONFIG_HEIGHT); String custom = (String)config.get(LTIConfigForm.CONFIG_KEY_CUSTOM); container.contextPut("height", height); container.contextPut("width", width); LTIContext context = new LTICourseNodeContext(courseEnv, courseNode, ltiRoles, sourcedId, backMapperUri, outcomeMapperUri, custom, target, width, height); Map<String,String> unsignedProps = ltiManager.forgeLTIProperties(getIdentity(), getLocale(), context, sendname, sendmail); Mapper contentMapper = new PostDataMapper(unsignedProps, url, oauth_consumer_key, oauth_secret, "true".equals(debug)); String mapperUri = registerMapper(ureq, contentMapper); container.contextPut("mapperUri", mapperUri + "/"); } private String getLTIRoles() { if (roles.isGuestOnly()) { return "Guest"; } CourseGroupManager groupManager = courseEnv.getCourseGroupManager(); boolean admin = groupManager.isIdentityCourseAdministrator(getIdentity()); if(admin || roles.isOLATAdmin()) { String authorRole = config.getStringValue(BasicLTICourseNode.CONFIG_KEY_AUTHORROLE); if(StringHelper.containsNonWhitespace(authorRole)) { return authorRole; } return "Instructor,Administrator"; } boolean coach = groupManager.isIdentityCourseCoach(getIdentity()); if(coach) { String coachRole = config.getStringValue(BasicLTICourseNode.CONFIG_KEY_COACHROLE); if(StringHelper.containsNonWhitespace(coachRole)) { return coachRole; } return "Instructor"; } String participantRole = config.getStringValue(BasicLTICourseNode.CONFIG_KEY_PARTICIPANTROLE); if(StringHelper.containsNonWhitespace(participantRole)) { return participantRole; } return "Learner"; } @Override protected void doDispose() { // } }