Java tutorial
/** * Copyright (c) 2009 i>clicker (R) <http://www.iclicker.com/dnn/> * * This file is part of i>clicker Sakai integrate. * * i>clicker Sakai integrate is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * i>clicker Sakai integrate is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with i>clicker Sakai integrate. If not, see <http://www.gnu.org/licenses/>. */ package org.sakaiproject.iclicker.logic; import org.apache.commons.codec.binary.Base64; import org.apache.commons.codec.binary.Hex; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.lang.RandomStringUtils; import org.apache.commons.lang.StringEscapeUtils; import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.sakaiproject.entitybroker.util.http.HttpRESTUtils; import org.sakaiproject.entitybroker.util.http.HttpResponse; import org.sakaiproject.genericdao.api.search.Order; import org.sakaiproject.genericdao.api.search.Restriction; import org.sakaiproject.genericdao.api.search.Search; import org.sakaiproject.iclicker.dao.IClickerDao; import org.sakaiproject.iclicker.logic.ClickerIdInvalidException.Failure; import org.sakaiproject.iclicker.model.ClickerRegistration; import org.sakaiproject.iclicker.model.ClickerUserKey; import org.springframework.context.NoSuchMessageException; import org.springframework.context.support.ReloadableResourceBundleMessageSource; import org.springframework.dao.DataIntegrityViolationException; import org.w3c.dom.*; import org.xml.sax.SAXException; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.lang.ref.WeakReference; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.*; import java.util.Map.Entry; /** * This is the implementation of the business logic interface, * this handles all the business logic and processing for the application * * @author Aaron Zeckoski (azeckoski @ gmail.com) */ public class IClickerLogic { private static Log log = LogFactory.getLog(IClickerLogic.class); public static String VERSION = "1.5"; // should match the POM version public static String VERSION_DATE = "20140906"; // the date in YYYYMMDD // CONFIG public static String DEFAULT_SERVER_URL = "http://localhost/sakai"; public String serverId = "UNKNOWN_SERVER_ID"; public String serverURL = DEFAULT_SERVER_URL; public String domainURL = DEFAULT_SERVER_URL; /* private static int DEFAULT_SYNC_HOUR = 3; public String domainURL = DEFAULT_SERVER_URL; public Boolean useNationalWebservices = null; public String webservicesURL = NATIONAL_WS_URL; public boolean webservicesUseBasicAuth = true; public String webservicesBasicAuthHeader = NATIONAL_WS_BASIC_AUTH_HEADER; public boolean disableSyncWithNational = false; public int webservicesNationalSyncHour = DEFAULT_SYNC_HOUR; */ public String workspacePageTitle = "i>clicker"; public boolean disableAlternateRemoteID = false; public boolean forceRestDebugging = false; private String notifyEmailsString = null; private String[] notifyEmails = null; private boolean singleSignOnHandling = false; private String singleSignOnSharedkey = null; private int maxCoursesForInstructor = 100; private IClickerDao dao; private ExternalLogic externalLogic; private ReloadableResourceBundleMessageSource messageSource; /** * Place any code that should run when this class is initialized by spring here */ public void init() { log.info("INIT"); // store this so we can get the service later IClickerLogic.setInstance(this); serverURL = externalLogic.getConfigurationSetting(ExternalLogic.SETTING_SERVER_URL, DEFAULT_SERVER_URL); domainURL = serverURL; workspacePageTitle = externalLogic.getConfigurationSetting("iclicker.workspace.title", workspacePageTitle); disableAlternateRemoteID = externalLogic.getConfigurationSetting("iclicker.turn.off.alternate.remote.id", disableAlternateRemoteID); serverId = externalLogic.getConfigurationSetting(AbstractExternalLogic.SETTING_SERVER_ID, serverId); forceRestDebugging = externalLogic.getConfigurationSetting("iclicker.rest.debug", false); maxCoursesForInstructor = externalLogic.getConfigurationSetting("iclicker.max.courses", maxCoursesForInstructor); notifyEmailsString = externalLogic.getConfigurationSetting("iclicker.notify.emails", notifyEmailsString); if (notifyEmailsString == null) { // get from the server String email = externalLogic.getNotificationEmail(); if (email != null) { notifyEmails = new String[] { email }; } } else { notifyEmails = notifyEmailsString.split(","); if (notifyEmails.length == 0) { notifyEmails = null; log.warn("Invalid list of email addresses in iclicker.notify.emails config setting: " + notifyEmailsString); } else { for (int i = 0; i < notifyEmails.length; i++) { notifyEmails[i] = notifyEmails[i].trim(); } } } // Special SSO handling setSharedKey(externalLogic.getConfigurationSetting("iclicker.sso.shared.key", (String) null)); } private List<String> failures = new Vector<String>(); public List<String> getFailures() { return failures; } /** * Sends a notification to the list of admins, * this is primarily for notifications of failures related to webservices failures * * @param message the notification message to send * @param failure [OPTIONAL] the exception if there was one */ public void sendNotification(String message, Exception failure) { String body = "i>clicker Sakai integrate plugin notification (" + new Date() + ")\n" + message + "\n"; if (failure != null) { // get the stacktrace out StringWriter sw = new StringWriter(); PrintWriter pw = new PrintWriter(sw); failure.printStackTrace(pw); String stacktrace = "Full stacktrace:\n" + failure.getClass().getSimpleName() + ":" + failure.getMessage() + ":\n" + sw.toString(); body = body + "\nFailure:\n" + failure.toString() + "\n\n" + stacktrace; // add to failures record and trim it SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); failures.add(df.format(new Date()) + " :: " + StringUtils.abbreviate(message, 300)); while (failures.size() > 3) { failures.remove(0); } } if (notifyEmails != null && notifyEmails.length > 0) { externalLogic.sendEmails(null, notifyEmails, "i>clicker Sakai integrate plugin notification", body); } else { log.warn("No emails set for sending notifications: logging notification: " + body); } } /** * resolve the i18n message * * @param key the message key to lookup * @param locale the Locale in which to do the lookup * @param args Array of arguments that will be filled in for params within the message * (params look like "{0}", "{1,date}", "{2,time}" within a message), or null if none * @return the i18n string OR null if the key cannot be resolved */ public String getMessage(String key, Locale locale, Object... args) { String msg; try { msg = messageSource.getMessage(key, args, locale); } catch (NoSuchMessageException e) { msg = null; } return msg; } // ******************************************************************************* // SSO handling /** * Attempt to authenticate a user given a login name and password (or user passkey) * @param loginname the login name for the user * @param password the password for the user (might be the SSO user passkey) * @param ssoKey [OPTIONAL] the SSO encoded key if one exists in the request * @return the session ID if authenticated OR null if the auth parameters are invalid */ public String authenticate(String loginname, String password, String ssoKey) { String sessionId; if (singleSignOnHandling) { verifyKey(ssoKey); // verify the key is valid String userId = externalLogic.getUserIdFromLoginName(loginname); if (userId == null) { // invalid user! sessionId = null; } else { // check the user password against the SSO passkey if (checkUserKey(userId, password)) { // valid user and key so generate the session sessionId = externalLogic.startUserSessionById(userId); } else { // invalid user key sessionId = null; log.warn("Invalid user key passed in (" + password + ") for user (" + userId + ")"); } } } else { sessionId = externalLogic.authenticateUser(loginname, password, true); } return sessionId; } /** * Make or find a user key for the given user, * if they don't have one, this will create one, if they do it will retrieve it. * It can also be used to generate a new user key * * @param userId [OPTIONAL] the internal user id (if null, use the current user id) * @param makeNew if true, make a new key even if the user already has one, if false, only make a key if they do not have one * @return the user key for the given user * @throws IllegalStateException if user is not set or is not an instructor */ public String makeUserKey(String userId, boolean makeNew) { if (userId == null) { userId = externalLogic.getCurrentUserId(); } if (userId == null) { throw new IllegalStateException("no current user, cannot generate a user key"); } if (!externalLogic.isUserInstructor(userId) && !externalLogic.isUserAdmin(userId)) { // if user is not an instructor or an admin then we will not make a key for them, this is to block students from getting a pass key throw new IllegalStateException( "current user (" + userId + ") is not an instructor, cannot generate user key for them"); } // find the key for this user if one exists ClickerUserKey cuk = dao.findOneBySearch(ClickerUserKey.class, new Search(new Restriction("userId", userId))); if (makeNew && cuk != null) { // remove the existing key so we can make a new one dao.delete(cuk); cuk = null; } if (cuk == null) { // make a new key and store it String newKeyValue = RandomStringUtils.randomAlphanumeric(12); cuk = new ClickerUserKey(newKeyValue, userId); try { dao.save(cuk); } catch (DataIntegrityViolationException e) { // this should not have happened but it means the key already exists somehow, probably a sync issue of some kind log.warn("Failed when attempting to create a new clicker user key for :" + userId); } } return cuk.getUserKey(); } /** * Find the user key for a given user if one exists * * @param userId [OPTIONAL] the internal user id (if null, use the current user id) * @return the user key for the given user OR null if there is no key */ /* UNUSED public String getUserKey(String userId) { if (userId == null) { userId = externalLogic.getCurrentUserId(); } if (userId == null) { throw new IllegalStateException("no current user, cannot generate a user key"); } String key = null; // find the key for this user if one exists ClickerUserKey cuk = dao.findOneBySearch(ClickerUserKey.class, new Search( new Restriction("userId", userId) )); if (cuk != null) { key = cuk.getUserKey(); } return key; }*/ /** * Checks if the passed in user key is valid compared to the internally stored user key * * @param userId [OPTIONAL] the internal user id (if null, use the current user id) * @param userKey the passed in SSO key to check for this user * @return true if the key is valid OR false if the user has no key or the key is otherwise invalid */ public boolean checkUserKey(String userId, String userKey) { if (StringUtils.isEmpty(userKey)) { throw new IllegalArgumentException("userKey cannot be empty"); } if (userId == null) { userId = externalLogic.getCurrentUserId(); } if (userId == null) { throw new IllegalStateException("no current user, cannot check user key"); } boolean valid = false; ClickerUserKey cuk = dao.findOneBySearch(ClickerUserKey.class, new Search(new Restriction("userId", userId))); if (cuk != null) { if (userKey.equals(cuk.getUserKey())) { valid = true; } } if (forceRestDebugging) { log.info("REST debug checkUserKey (u=" + userId + ", v=" + valid + "): " + cuk); } return valid; } /** * @param sharedKey set and verify the shared key (if invalid, log a warning) */ protected void setSharedKey(String sharedKey) { if (sharedKey != null) { if (sharedKey.length() < 10) { log.warn("i>clicker shared key (" + sharedKey + ") is too short, must be at least 10 chars long. SSO shared key will be ignored until a longer key is entered."); } else { singleSignOnHandling = true; singleSignOnSharedkey = sharedKey; log.info( "i>clicker plugin SSO handling enabled by shared key, note that this will disable normal username/password handling"); } } } /** * @return the SSO shared key value (or empty string otherwise) * NOTE: this only works if SSO is enabled AND the user is an admin */ public String getSharedKey() { String key = ""; if (singleSignOnHandling && externalLogic.isUserAdmin(externalLogic.getCurrentUserId())) { key = singleSignOnSharedkey; } return key; } /** * @return true if SSO handling is enabled, false otherwise */ public boolean isSingleSignOnEnabled() { return singleSignOnHandling; } /** * Verify the passed in encrypted SSO shared key is valid, * this will return false if the key is not configured * * Key must have been encoded like so (where timestamp is the unix time in seconds): * sentKey = hex(sha1(sharedKey + ":" + timestamp)) + "|" + timestamp * * @param key the passed in key (should already be sha-1 and hex encoded with the timestamp appended) * @return true if the key is valid, false if SSO shared keys are disabled * @throws IllegalArgumentException if the key format is invalid * @throws SecurityException if the key timestamp has expired or the key does not match */ public boolean verifyKey(String key) { if (StringUtils.isEmpty(key)) { throw new IllegalArgumentException("key must be set in order to verify the key"); } boolean verified = false; if (singleSignOnHandling) { // encoding process requires the key and timestamp so split them from the passed in key int splitIndex = key.lastIndexOf('|'); if ((splitIndex == -1) || (key.length() < splitIndex + 1)) { throw new IllegalArgumentException("i>clicker shared key (" + key + ") format is invalid (no |), must be {encoded key}|{timestamp}"); } String actualKey = key.substring(0, splitIndex); if (StringUtils.isEmpty(actualKey)) { throw new IllegalArgumentException("i>clicker shared key (" + key + ") format is invalid (missing encoded key), must be {encoded key}|{timestamp}"); } String timestampStr = key.substring(splitIndex + 1); if (StringUtils.isEmpty(timestampStr)) { throw new IllegalArgumentException("i>clicker shared key (" + key + ") format is invalid (missing timestamp), must be {encoded key}|{timestamp}"); } long timestamp; try { timestamp = Long.parseLong(timestampStr); } catch (NumberFormatException e) { throw new IllegalArgumentException("i>clicker shared key (" + key + ") format is invalid (non numeric timestamp), must be {encoded key}|{timestamp}"); } // check this key is still good (must be within 5 mins of now) long unixTime = System.currentTimeMillis() / 1000l; long timeDiff = Math.abs(timestamp - unixTime); if (timeDiff > 300l) { throw new SecurityException( "i>clicker shared key (" + key + ") timestamp is out of date, this timestamp (" + timestamp + ") is more than 5 minutes different from the current time (" + unixTime + ")"); } // finally we verify the key with the one in the config byte[] sha1Bytes = DigestUtils.sha(singleSignOnSharedkey + ":" + timestamp); String sha1Hex = Hex.encodeHexString(sha1Bytes); if (!actualKey.equals(sha1Hex)) { throw new SecurityException( "i>clicker encoded shared key (" + key + ") does not match with the key (" + sha1Hex + ") in Sakai (using timestamp: " + timestamp + ")"); } verified = true; } return verified; } // ******************************************************************************* // Admin i>clicker tool in workspace handling /** * Special tracker to see if the system is already running a thread, * this is meant to ensure that more than one large scale operation is not * running at once */ private WeakReference<BigRunner> runnerHolder; @SuppressWarnings("FieldCanBeLocal") private static String ICLICKER_TOOL_ID = "sakai.iclicker"; /** * Executes the add or remove tool workspace operation * @param type the type of runner to make, from BigRunner.RUNNER_TYPE_* * @return the runner object indicating progress * @throws IllegalArgumentException if the runner type is unknown * @throws IllegalStateException if there is a runner in progress of a different type * @throws ClickerLockException if a lock cannot be obtained */ public BigRunner startRunnerOperation(String type) { BigRunner runner = getRunnerStatus(); if (runner != null) { // allow the runner to be cleared if done if (runner.isComplete()) { runner = null; } else { if (!type.equals(runner.getType())) { throw new IllegalStateException( "Already running a big runner of a different type: " + runner.getType()); } } } if (runner == null) { // try to obtain a lock Boolean gotLock = dao.obtainLock(BigRunner.RUNNER_LOCK, serverId, 600000); // expire 10 mins if (gotLock != null && gotLock) { if (BigRunner.RUNNER_TYPE_ADD.equalsIgnoreCase(type)) { runner = getExternalLogic().makeAddToolToWorkspacesRunner(ICLICKER_TOOL_ID, workspacePageTitle, null); } else if (BigRunner.RUNNER_TYPE_REMOVE.equalsIgnoreCase(type)) { runner = getExternalLogic().makeRemoveToolFromWorkspacesRunner(ICLICKER_TOOL_ID); } else { throw new IllegalArgumentException("Unknown type of runner operation: " + type); } final BigRunner bigRunner = runner; Runnable runnable = new Runnable() { public void run() { try { bigRunner.run(); } catch (Exception e) { String msg = "long running process (" + bigRunner + ") failure: " + e; sendNotification(msg, e); log.warn(msg, e); // sleep for 5 secs to hold the error state so it can be checked try { Thread.sleep(5000); } catch (InterruptedException e1) { // nothing to do here } } finally { clearRunner(); // when done dao.releaseLock(BigRunner.RUNNER_LOCK, serverId); } } }; new Thread(runnable).start(); // start up a thread to run this log.info("Starting new long running process (" + runner + ")"); this.runnerHolder = new WeakReference<BigRunner>(runner); } else { // failed to obtain the lock String msg = "Could not obtain a lock (" + BigRunner.RUNNER_LOCK + ") on server (" + serverId + "): " + gotLock; log.info(msg); throw new ClickerLockException(msg, BigRunner.RUNNER_LOCK, serverId); } } return runner; } /** * Get the currently running process if there is one * @return the runner OR null if there is no running process */ public BigRunner getRunnerStatus() { return this.runnerHolder != null ? this.runnerHolder.get() : null; } /** * clears the holder */ public void clearRunner() { if (runnerHolder != null) { runnerHolder.clear(); runnerHolder = null; } } // ******************************************************************************* // Clicker registration handling /** * This returns an item based on an id if the user is allowed to access it * @param id the id of the item to fetch * @return a ClickerRegistration or null if none found * @throws SecurityException if the current user cannot access this item */ public ClickerRegistration getItemById(Long id) { log.debug("Getting item by id: " + id); ClickerRegistration item = dao.findById(ClickerRegistration.class, id); if (item != null) { if (!canReadItem(item, externalLogic.getCurrentUserId())) { throw new SecurityException("User (" + externalLogic.getCurrentUserId() + ") not allowed to access registration (" + item + ")"); } } return item; } /** * This returns an item based on a clickerId for the current user if allowed to access it, * this will return a null if the clickerId happens to be invalid or cannot be found * * @param clickerId the clicker remote ID * @return a ClickerRegistration OR null if none found * @throws SecurityException if the current user cannot access this item */ public ClickerRegistration getItemByClickerId(String clickerId) { return getItemByClickerId(clickerId, null); } /** * This returns an item based on a clickerId and ownerId if the user is allowed to access it, * this will return a null if the clickerId is invalid or cannot be found * * @param clickerId the clicker remote ID * @param ownerId the clicker owner ID (user id) * @return a ClickerRegistration OR null if none found * @throws SecurityException if the current user cannot access this item */ public ClickerRegistration getItemByClickerId(String clickerId, String ownerId) { log.debug("Getting item by clickerId: " + clickerId); String userId = externalLogic.getCurrentUserId(); if (userId == null) { throw new SecurityException("user must be logged in"); } if (ownerId != null) { userId = ownerId; } try { clickerId = validateClickerId(clickerId); } catch (ClickerIdInvalidException e) { return null; } ClickerRegistration item = dao.findOneBySearch(ClickerRegistration.class, new Search( new Restriction[] { new Restriction("clickerId", clickerId), new Restriction("ownerId", userId) })); if (item != null) { if (!canReadItem(item, externalLogic.getCurrentUserId())) { throw new SecurityException("User (" + externalLogic.getCurrentUserId() + ") not allowed to access registration (" + item + ")"); } } return item; } /** * @param item registration * @param userId sakai user id * @return true if the item can be read, false otherwise */ @SuppressWarnings("BooleanMethodIsAlwaysInverted") public boolean canReadItem(ClickerRegistration item, String userId) { log.debug("checking if can read for: " + userId + " and item=" + item); String locationId = ""; if (item.getOwnerId().equals(userId)) { // owner can always read an item return true; } else if (externalLogic.isUserAdmin(userId)) { // the system super user can read any item return true; } else if (locationId.equals(item.getLocationId())) { // users with permission in the specified site can modify items from that site return true; } else if (externalLogic.isInstructorOfUser(item.getOwnerId()) != null) { //externalLogic.isUserInstructor(userId) ) { // we are allowing instructors to read items for students in their course return true; } return false; } /** * Check if a specified user can write this item in a specified site * @param item to be modified or removed * @param userId the internal user id (not username) * @return true if item can be modified, false otherwise */ public boolean canWriteItem(ClickerRegistration item, String userId) { log.debug("checking if can write for: " + userId + " and item=" + item); if (item.getOwnerId().equals(userId)) { // owner can always modify an item return true; } else if (externalLogic.isUserAdmin(userId)) { // the system super user can modify any item return true; } else if (externalLogic.isInstructorOfUser(item.getOwnerId()) != null) { //externalLogic.isUserInstructor(userId) ) { // we are allowing instructors to write items for students in their course return true; } return false; } /** * This returns a List of items that are visible for a specified user * @param userId the internal user id (not username) * @param locationId [OPTIONAL] a unique id which represents the current location of the user (entity reference) * @return a List of ClickerRegistration objects */ public List<ClickerRegistration> getAllVisibleItems(String userId, String locationId) { log.debug("Fetching visible items for " + userId + " in site: " + locationId); List<ClickerRegistration> l; if (locationId == null) { // get the items for this user only l = dao.findBySearch(ClickerRegistration.class, new Search( new Restriction[] { new Restriction("ownerId", userId), new Restriction("activated", true) })); } else { // inst gets registrations for themselves only // TAs still able to get their remote registrations this way // search by location would not be helpful here if (externalLogic.isUserAdmin(userId)) { // admin gets all items when requesting by location? l = dao.findAll(ClickerRegistration.class); } else { // student gets registrations for themselves only always l = dao.findBySearch(ClickerRegistration.class, new Search(new Restriction[] { new Restriction("ownerId", userId), new Restriction("activated", true) })); } } return l; } /** * ADMIN ONLY * Only the admin can use this method to retrieve all clicker IDs * @param first the first item (for paging and limiting) * @param max the max number of items to return * @param order [OPTIONAL] sort order for the items * @param searchStr [OPTIONAL] search by partial clickerId * @param includeUserDisplayNames if true the user display names are added to the results * @return a list of clicker registrations */ public List<ClickerRegistration> getAllItems(int first, int max, String order, String searchStr, boolean includeUserDisplayNames) { // admin only if (!externalLogic.isUserAdmin(externalLogic.getCurrentUserId())) { throw new SecurityException("Only admins can get the listing of all clicker registrations"); } if (order == null || "".equals(order)) { order = "clickerId"; } Search s = new Search(); s.setStart(first); s.setLimit(max); s.addOrder(new Order(order)); if (searchStr != null && !"".equals(searchStr)) { // maybe allow search on more than clickerId later s.addRestriction(new Restriction("clickerId", searchStr, Restriction.LIKE)); } List<ClickerRegistration> l = dao.findBySearch(ClickerRegistration.class, s); // optionally include the user names if (includeUserDisplayNames) { for (ClickerRegistration reg : l) { reg.userDisplayName = getUserDisplayName(reg.getOwnerId()); } } return l; } public int countAllItems() { return dao.countAll(ClickerRegistration.class); } public String getUserDisplayName(String userId) { if (userId == null || "".equals(userId)) { userId = externalLogic.getCurrentUserId(); } String name = externalLogic.getUserDisplayName(userId); if (name == null || "".equals(name)) { name = userId; } return name; } /** * Remove an item * NOTE: only admins can fully remove a registration * @param item the ClickerRegistration to remove * @throws SecurityException if the user not allowed to remove the registration */ public void removeItem(ClickerRegistration item) { log.debug("In removeItem with item:" + item); // check if current user can remove this item if (externalLogic.isUserAdmin(externalLogic.getCurrentUserId())) { dao.delete(item); log.info("Removing clicker registration: " + item); } else { throw new SecurityException("Current user cannot remove registration " + item + " because they do not have permission, only admins can remove"); } } /** * Save (Create or Update) an item (uses the current site) * @param item the ClickerRegistration to create or update * @throws IllegalArgumentException if the item is null OR the owner id is not a valid user * @throws SecurityException if the user cannot save the registration for lack of perms * @throws ClickerIdInvalidException if the clicker ID is invalid for some reason, * the exception will indicate the type of validation failure */ public void saveItem(ClickerRegistration item) { log.debug("In saveItem with item:" + item); if (item == null) { throw new IllegalArgumentException("item cannot be null"); } String clickerId = StringUtils.trimToNull(item.getClickerId()); if (item.isActivated() || clickerId == null) { // only validate when activating or clearly invalid clickerId = validateClickerId(item.getClickerId()); } item.setClickerId(clickerId); // set the owner to current if not set if (item.getOwnerId() == null) { item.setOwnerId(externalLogic.getCurrentUserId()); } else { // check for valid user id User u = externalLogic.getUser(item.getOwnerId()); if (u == null) { throw new IllegalArgumentException( "user id (" + item.getOwnerId() + ") is invalid (cannot match to user)"); } } Date now = new Date(); if (item.getDateCreated() == null) { item.setDateCreated(now); } item.setDateModified(now); // save item if new OR check if the current user can update the existing item if ((item.getId() == null) || canWriteItem(item, externalLogic.getCurrentUserId())) { dao.save(item); log.info("Saving clicker registration: " + item); } else { throw new SecurityException( "Current user cannot update item " + item.getId() + " because they do not have permission"); } } /** * Creates a new clicker remote registration in the system for the current user, * also push to national * * @param clickerId the clicker remote ID * @return the registration * @throws ClickerIdInvalidException if the clicker ID is invalid for some reason, * the exception will indicate the type of validation failure * @throws ClickerRegisteredException if the clickerId is already registered * @throws SecurityException if the user cannot save the registration for lacks of perms */ public ClickerRegistration createRegistration(String clickerId) { return createRegistration(clickerId, null); } /** * Creates a new clicker remote registration in the system, * will push the registration to national as well * * @param clickerId the clicker remote ID * @param ownerId the owner of this registration * @return the registration * @throws ClickerIdInvalidException if the clicker ID is invalid for some reason, * the exception will indicate the type of validation failure * @throws ClickerRegisteredException if the clickerId is already registered * @throws IllegalArgumentException if the owner id is not a valid user * @throws SecurityException if the user cannot save the registration for lacks of perms */ public ClickerRegistration createRegistration(String clickerId, String ownerId) { clickerId = validateClickerId(clickerId); String currentUserId = externalLogic.getCurrentUserId(); if (currentUserId == null) { throw new SecurityException("user must be logged in"); } String userId = ownerId; if (ownerId == null || "".equals(ownerId)) { userId = currentUserId; } ClickerRegistration registration = getItemByClickerId(clickerId, userId); // NOTE: we probably want to check the national system here to see if this is already registered if (registration != null) { if (registration.getOwnerId().equals(currentUserId)) { // reactivate the clicker if needed if (!registration.isActivated()) { registration.setActivated(true); saveItem(registration); } } else { throw new ClickerRegisteredException(userId, registration.getClickerId(), registration.getOwnerId()); //throw new IllegalStateException("Registration exists with clickerId ("+clickerId+") for the current user ("+userId+")"); } } else { registration = new ClickerRegistration(clickerId, userId); saveItem(registration); //syncClickerRegistrationWithNational(registration); } return registration; } /** * Create the registration locally only * * @param clickerId the clicker remote ID * @param ownerId the owner of this registration * @return the registration * @throws ClickerIdInvalidException if the clicker ID is invalid for some reason, * the exception will indicate the type of validation failure * @throws ClickerRegisteredException if the clickerId is already registered * @throws IllegalArgumentException if the owner id is not a valid user * @throws SecurityException if the user cannot save the registration for lacks of perms */ /* UNUSED protected ClickerRegistration localCreateRegistration(String clickerId, String ownerId) { clickerId = validateClickerId(clickerId); String currentUserId = externalLogic.getCurrentUserId(); if (currentUserId == null) { throw new SecurityException("user must be logged in"); } String userId = ownerId; if (ownerId == null || "".equals(ownerId)) { userId = currentUserId; } ClickerRegistration registration = getItemByClickerId(clickerId, ownerId); if (registration != null) { throw new ClickerRegisteredException(userId, registration.getClickerId(), registration.getOwnerId()); //throw new IllegalStateException("Registration exists with clickerId ("+clickerId+") for the current user ("+userId+")"); } else { registration = new ClickerRegistration(clickerId, userId); saveItem(registration); } return registration; }*/ /** * Updates the activation of a registration for the current user * @param registrationId the unique id of the registration * @param activated the new activation level * @return the registration if it was updated, null if not updated * @throws IllegalArgumentException if the registrationId is invalid * @throws SecurityException is the current user cannot update the registration */ public ClickerRegistration setRegistrationActive(Long registrationId, boolean activated) { if (registrationId == null) { throw new IllegalArgumentException("registrationId cannot be null"); } ClickerRegistration registration = getItemById(registrationId); if (registration == null) { throw new IllegalArgumentException("Could not find registration with id: " + registrationId); } String userId = externalLogic.getCurrentUserId(); if (!canWriteItem(registration, userId)) { throw new SecurityException("User (" + userId + ") cannot update registration (" + registration + ")"); } boolean current = registration.isActivated(); if (current != activated) { registration.setActivated(activated); saveItem(registration); return registration; } return null; } public List<Student> getStudentsForCourseWithClickerReg(String courseId) { List<Student> students = externalLogic.getStudentsForCourse(courseId); if (students != null && !students.isEmpty()) { // populate clickerRegistration data // get the set of all registrations Search search = new Search(); search.addRestriction(new Restriction("activated", true)); // only active ones //noinspection StatementWithEmptyBody if (students.size() > 500) { // just get all the registrations since this class is insanely huge } else { String[] owners = new String[students.size()]; for (int i = 0; i < students.size(); i++) { owners[i] = students.get(i).userId; } search.addRestriction(new Restriction("ownerId", owners)); } search.addOrder(new Order("ownerId")); List<ClickerRegistration> l = dao.findBySearch(ClickerRegistration.class, search); // create map of registrations to owners HashMap<String, Set<ClickerRegistration>> ownerToReg = new HashMap<String, Set<ClickerRegistration>>( students.size()); for (ClickerRegistration cr : l) { if (!ownerToReg.containsKey(cr.getOwnerId())) { ownerToReg.put(cr.getOwnerId(), new HashSet<ClickerRegistration>()); } ownerToReg.get(cr.getOwnerId()).add(cr); } // now merge the set of registrations with the set of students for (Student student : students) { Set<ClickerRegistration> crs = ownerToReg.get(student.userId); if (crs == null || crs.isEmpty()) { student.clickerRegistered = Boolean.FALSE; } else { student.clickerRegistered = Boolean.TRUE; student.clickerRegistrations = crs; } } } return students; } /** * Get the clicker registrations for a given user * * @param userId the id of the user * @param activeOnly if true only get the active registrations, else get all * @return the list of registrations for this user */ public List<ClickerRegistration> getClickerRegistrationsForUser(String userId, boolean activeOnly) { Search search = new Search(); if (activeOnly) { search.addRestriction(new Restriction("activated", true)); // only active ones } search.addRestriction(new Restriction("ownerId", userId)); search.addOrder(new Order("dateCreated", false)); return dao.findBySearch(ClickerRegistration.class, search); } /** * Get all the courses for the given user, note that this needs to be limited from * outside this method for security, * if the return is limited to a single course then the students are included * * @param courseId [OPTIONAL] limit the return to just this one site * @return the courses (up to 100 of them) which the user has instructor access in */ public List<Course> getCoursesForInstructorWithStudents(String courseId) { List<Course> courses = externalLogic.getCoursesForInstructor(courseId, maxCoursesForInstructor); if (courseId != null && courses.size() == 1) { // add in the students Course c = courses.get(0); c.students = getStudentsForCourseWithClickerReg(c.id); } return courses; } /** * @param courseId * a unique id which represents the current location of the user (entity reference) * @return the title for the context or "--------" (8 hyphens) if none found */ public String getCourseTitle(String courseId) { return externalLogic.getLocationTitle(courseId); } /** * Gets the gradebook data for a given site, * this uses the gradebook security so it is safe for * anyone to call * * @param courseId a sakai siteId (cannot be group Id) * @param gbItemName [OPTIONAL] an item name to fetch from this gradebook (limit to this item only), * if null then all items are returned */ public Gradebook getCourseGradebook(String courseId, String gbItemName) { Gradebook gb = externalLogic.getCourseGradebook(courseId, gbItemName); gb.students = getStudentsForCourseWithClickerReg(courseId); return gb; } /** * Save a gradebook item and optionally the scores within <br/> * Scores must have at least the studentId or username AND the grade set * * @param gbItem the gradebook item to save, must have at least the gradebookId and name set * @return the updated gradebook item and scores, contains any errors that occurred */ public GradebookItem saveGradebookItem(GradebookItem gbItem) { return externalLogic.saveGradebookItem(gbItem); } /** * Save a gradebook (saves all items in the gradebook) * @param gb the gradebook to save * @return the updated gradebook items and scores, contains any errors that occurred */ public List<GradebookItem> saveGradebook(Gradebook gb) { ArrayList<GradebookItem> items = new ArrayList<GradebookItem>(); if (gb != null) { for (GradebookItem gradebookItem : gb.items) { gradebookItem.gradebookId = gb.id; items.add(saveGradebookItem(gradebookItem)); } } return items; } /* * Remove a gradebook item (this is the only way to remove grades completely) * * @param courseId a sakai siteId (cannot be group Id) * @param itemName the unique name of the gb item to remove * @return true if the item was found and removed OR false otherwise */ /* Not possible with the 2.5 gradebook * or maybe it is * Would this work then? gradebookService.setAssignmentScore(gradebookUid, gbItem.name, studentId, dScore, "entitybroker score update"); where dScore is null to remove the score and non-null to set it? public boolean removeGradebookItem(String courseId, String itemName) { return externalLogic.removeGradebookItem(courseId, itemName); } ***/ // ********************************************************************************** // DATA encoding methods - put the data into the format desired by iclicker public String encodeClickerRegistration(ClickerRegistration registration) { if (registration == null) { throw new IllegalArgumentException("registration must be set"); } /* SAMPLE <Register> <S FirstName="Tim" LastName="Stelzer" StudentID="tstelzer" URL="http://www.iclicker.com/" clickerID="11111111" Enabled="True"></S> </Register> */ User user = externalLogic.getUser(registration.getOwnerId()); if (user == null) { throw new IllegalStateException("Could not get info about the user (" + registration.getOwnerId() + ") related to this clicker registration"); } @SuppressWarnings("StringBufferReplaceableByString") StringBuilder sb = new StringBuilder(); sb.append("<Register>\n"); sb.append(" <S DisplayName=\""); sb.append(escapeForXML(user.getName())); sb.append("\" FirstName=\""); sb.append(escapeForXML(user.fname)); sb.append("\" LastName=\""); sb.append(escapeForXML(user.lname)); sb.append("\" StudentID=\""); sb.append(escapeForXML(user.getUsername()).toUpperCase()); sb.append("\" Email=\""); sb.append(escapeForXML(user.getEmail())); sb.append("\" URL=\""); sb.append(escapeForXML(domainURL)); sb.append("\" ClickerID=\""); sb.append(escapeForXML(registration.getClickerId()).toUpperCase()); sb.append("\" Enabled=\""); sb.append(registration.isActivated() ? "True" : "False"); sb.append("\"></S>\n"); // close out sb.append("</Register>\n"); return sb.toString(); } /** * Encode response from registration of clicker data * This option should be available where the instructor already has the clicker registration file (Remoteid.csv) * and wants to upload the registration(s) to the CMS Server. * * @param registrations the registrations resulting from the action * @param status true if new registration, false otherwise * @param message the human readable message * @return the encoded XML * @throws IllegalStateException if the user cannot be found * @throws IllegalArgumentException if the data is invalid */ public String encodeClickerRegistrationResult(List<ClickerRegistration> registrations, boolean status, String message) { if (registrations == null || registrations.isEmpty()) { throw new IllegalArgumentException("registrations must be set"); } if (message == null) { throw new IllegalArgumentException("message must be set"); } /* SAMPLE 1) When clicker is already registered to some one else - the same message should be returned that is displayed in the plug-in in xml format <RetStatus Status="False" Message=""/> 2) When clicker is already registered to the same user - the same message should be returned that is displayed in the plug-in in xml format. <RetStatus Status="False" Message=""/> 3) When studentid is not found in the CMS <RetStatus Status="False" Message="Student not found in the CMS"/> 4) Successful registration - <RetStatus Status="True" Message="..."/> */ return "<RetStatus Status=\"" + (status ? "True" : "False") + "\" Message=\"" + escapeForXML(message) + "\" />"; } /** * The xml format to upload students registration to the CMS Server remains the same as the national registration * web services and this upload should be treated as if the user is registering the remotes manually inside the * plug-in i.e all applicable messages should be returned <br/> * NOTE: we are ignoring the email and name inputs because we will get them from the user lookup of the id * * @param xml XML * @return the clicker registration object from the xml * @throws IllegalArgumentException if the xml is invalid or blank * @throws RuntimeException if there is an internal failure in the XML parser */ public ClickerRegistration decodeClickerRegistration(String xml) { /* <Register> <S DisplayName="DisplayName-azeckoski-123456" FirstName="First" LastName="Lastazeckoski-123456" StudentID="eid-azeckoski-123456" Email="azeckoski-123456@email.com" URL="http://sakaiproject.org"; ClickerID="11111111"></S> </Register> */ if (xml == null || "".equals(xml)) { throw new IllegalArgumentException("xml must be set"); } // read the xml (try to anyway) DocumentBuilder db; try { db = DocumentBuilderFactory.newInstance().newDocumentBuilder(); } catch (ParserConfigurationException e) { e.printStackTrace(); throw new RuntimeException("XML parser failure: " + e, e); } Document doc; try { doc = db.parse(new ByteArrayInputStream(xml.getBytes())); } catch (SAXException e) { e.printStackTrace(); throw new RuntimeException("XML read failure: " + e, e); } catch (IOException e) { e.printStackTrace(); throw new RuntimeException("XML IO failure: " + e, e); } ClickerRegistration cr; try { doc.getDocumentElement().normalize(); NodeList users = doc.getElementsByTagName("S"); if (users.getLength() == 0) { throw new IllegalArgumentException("Invalid XML, no S element"); } Node userNode = users.item(0); if (userNode.getNodeType() == Node.ELEMENT_NODE) { Element user = (Element) userNode; String clickerId = user.getAttribute("ClickerID"); if (clickerId == null || "".equals(clickerId)) { throw new IllegalArgumentException( "Invalid XML for registration, no id in the ClickerID element (Cannot process)"); } String userId = user.getAttribute("StudentID"); // this is the userId if (userId == null || "".equals(userId)) { throw new IllegalArgumentException( "Invalid XML for registration, no id in the StudentID element (Cannot process)"); } cr = new ClickerRegistration(clickerId, userId); cr.userDisplayName = user.getAttribute("DisplayName"); } else { throw new IllegalArgumentException("Invalid user node in XML: " + userNode); } } catch (DOMException e) { e.printStackTrace(); throw new RuntimeException("XML DOM parsing failure: " + e, e); } return cr; } public String encodeCourses(String instructorId, List<Course> courses) { if (courses == null) { throw new IllegalArgumentException("courses must be set"); } /* SAMPLE <coursemembership username="test_instructor01"> <course id="BFW61" name="BFW - iClicker Test" created="111111111" published="true" usertype="I" /> </coursemembership> */ User user = externalLogic.getUser(instructorId); if (user == null) { throw new IllegalStateException( "Could not get info about the user (" + instructorId + ") related to the course listing"); } StringBuilder sb = new StringBuilder(); sb.append("<coursemembership username=\""); sb.append(escapeForXML(user.getUsername())); sb.append("\">\n"); // loop through courses for (Course course : courses) { sb.append(" <course id=\""); sb.append(escapeForXML(course.getId())); sb.append("\" name=\""); sb.append(escapeForXML(course.getTitle())); sb.append("\" usertype=\""); sb.append("I"); sb.append("\" created=\""); sb.append(course.getCreatedTime()); sb.append("\" published=\""); sb.append(course.isPublished() ? "True" : "False"); sb.append("\" />\n"); } // close out sb.append("</coursemembership>\n"); return sb.toString(); } /* UNUSED public List<ClickerRegistration> decodeNationalXML(String xml) { if (xml == null || "".equals(xml)) { throw new IllegalArgumentException("xml must be set"); } /* <StudentRoster> <S StudentID="student01" FirstName="student01" LastName="student01" URL="https://www.iclicker.com/" CourseName=""> <Registration ClickerId="12CE32EE" WhenAdded="2009-01-27" Enabled="True" /> </S> </StudentRoster> *//* ArrayList<ClickerRegistration> l = new ArrayList<ClickerRegistration>(); DateFormat df = new SimpleDateFormat("yyyy-MM-dd"); // read the xml (try to anyway) DocumentBuilder db; try { db = DocumentBuilderFactory.newInstance().newDocumentBuilder(); } catch (ParserConfigurationException e) { e.printStackTrace(); throw new RuntimeException("XML parser failure: " + e, e); } Document doc; try { doc = db.parse( new ByteArrayInputStream(xml.getBytes()) ); } catch (SAXException e) { e.printStackTrace(); throw new RuntimeException("XML read failure: " + e, e); } catch (IOException e) { e.printStackTrace(); throw new RuntimeException("XML IO failure: " + e, e); } try { doc.getDocumentElement().normalize(); // root NodeList users = doc.getElementsByTagName("S"); if (users.getLength() > 0) { for (int i = 0; i < users.getLength(); i++) { Node userNode = users.item(i); if (userNode.getNodeType() == Node.ELEMENT_NODE) { Element user = (Element) userNode; String studentId = user.getAttribute("StudentID"); // this is the user eid if (studentId == null || "".equals(studentId)) { throw new IllegalArgumentException("Invalid XML for registration, no id in the StudentID element (Cannot process)"); } studentId = studentId.toLowerCase(); User u = externalLogic.getUser(studentId); if (u == null) { log.warn("Cannot idenitfy user (id="+studentId+") in the national webservices feed, skipping this user"); continue; } String userId = u.getUserId(); NodeList regNodes = user.getElementsByTagName("Registration"); if (regNodes.getLength() > 0) { for (int j = 0; j < regNodes.getLength(); j++) { Node regNode = regNodes.item(j); if (regNode.getNodeType() == Node.ELEMENT_NODE) { Element registration = (Element) regNode; String clickerId = registration.getAttribute("ClickerId"); if (clickerId == null || "".equals(clickerId)) { log.warn("Missing clickerId in webservices registration XML line, skipping this registration for user: " + userId); continue; } String whenAdded = registration.getAttribute("WhenAdded"); Date dateCreated = new Date(); if (whenAdded != null && ! "".equals(whenAdded)) { try { dateCreated = df.parse(whenAdded); } catch (ParseException e) { log.warn("Could not parse date in webservices registration XML line: " + whenAdded); } } String enabled = registration.getAttribute("Enabled"); boolean activated = true; if (enabled != null && ! "".equals(enabled)) { activated = Boolean.valueOf(enabled); } ClickerRegistration cr = new ClickerRegistration(clickerId, userId); cr.setDateCreated(dateCreated); cr.setActivated(activated); l.add(cr); } else { log.warn("Invalid registration node in XML (skipping this one): " + regNode); } } } } else { log.warn("Invalid user node in XML (skipping this one): " + userNode); } } } } catch (DOMException e) { e.printStackTrace(); throw new RuntimeException("XML DOM parsing failure: " + e, e); } return l; }*/ private static String SCORE_KEY = "${SCORE}"; public String encodeGradebook(Gradebook gradebook) { if (gradebook == null) { throw new IllegalArgumentException("gradebook must be set"); } /* SAMPLE <coursegradebook courseid="BFW61"> <user id="lm_student01" usertype="S"> <lineitem name="06/02/2009" pointspossible="50" type="iclicker polling scores" score="0"/> </user> </coursegradebook> */ // first make the map of lineitems strings and scores Map<String, String> lineitems = makeLineitemsMap(gradebook.items); Map<String, GradebookItemScore> studentGradeitemScores = makeGBItemScoresMap(gradebook.items); // make XML StringBuilder sb = new StringBuilder(); // now make the outer course stuff sb.append("<coursegradebook courseid=\""); sb.append(escapeForXML(gradebook.courseId)); sb.append("\">\n"); // loop through the students for (Student student : gradebook.students) { sb.append(" <user id=\""); sb.append(escapeForXML(student.username)); sb.append("\" usertype=\"S\">\n"); // put in the lineitems with scores for (Entry<String, String> entry : lineitems.entrySet()) { String scoreKey = student.getUserId() + entry.getKey(); GradebookItemScore score = studentGradeitemScores.get(scoreKey); if (score != null) { String lineitem = entry.getValue().replace(SCORE_KEY, score.grade); sb.append(" "); sb.append(lineitem); sb.append("\n"); } } // close student sb.append(" </user>\n"); } // close out sb.append("</coursegradebook>\n"); return sb.toString(); } private Map<String, GradebookItemScore> makeGBItemScoresMap(List<GradebookItem> gradebookItems) { HashMap<String, GradebookItemScore> studentGradeitemScores = new HashMap<String, GradebookItemScore>(); for (GradebookItem gbItem : gradebookItems) { // store the scores into a map as well for (GradebookItemScore score : gbItem.scores) { String key = score.userId + gbItem.name; studentGradeitemScores.put(key, score); } } return studentGradeitemScores; } private Map<String, String> makeLineitemsMap(List<GradebookItem> gradebookItems) { LinkedHashMap<String, String> lineitems = new LinkedHashMap<String, String>(); for (GradebookItem gbItem : gradebookItems) { lineitems.put(gbItem.name, "<lineitem name=\"" + escapeForXML(gbItem.name) + "\" pointspossible=\"" + (gbItem.pointsPossible == null ? "" : gbItem.pointsPossible) + "\" type=\"" + (gbItem.type == null ? "" : escapeForXML(gbItem.type)) + "\" score=\"" + SCORE_KEY + "\"/>"); } return lineitems; } public String encodeEnrollments(Course course) { if (course == null) { throw new IllegalArgumentException("course must be set"); } /* SAMPLE <courseenrollment courseid="9ebcb080-02b6-43a9-8dc5-6aef890db579"> <user id="dbcc75e8-caeb-4e1d-b165-83402208da6e" usertype="S" firstname="Student" lastname="3333" emailid="" uniqueid="stud3" clickerid="" whenadded="" /> <user id="1d7bc55c-4d84-4099-a8e9-821fad061dc8" usertype="S" firstname="Studnet" lastname="One" emailid="" uniqueid="stud1" clickerid="" whenadded="" /> </courseenrollment> */ StringBuilder sb = new StringBuilder(); sb.append("<courseenrollment courseid=\""); sb.append(escapeForXML(course.getId())); sb.append("\">\n"); // loop through students for (Student student : course.students) { // get the clicker data out first if there is any String[] cidsDates = makeClickerIdsAndDates(student.getClickerRegistrations()); // now make the actual user data line sb.append(" <user id=\""); sb.append(student.getUserId()); sb.append("\" usertype=\""); sb.append("S"); sb.append("\" firstname=\""); sb.append(escapeForXML(student.fname == null ? "" : student.fname)); sb.append("\" lastname=\""); sb.append(escapeForXML(student.lname == null ? "" : student.lname)); sb.append("\" emailid=\""); sb.append(escapeForXML(student.getEmail() == null ? "" : student.getEmail())); sb.append("\" uniqueid=\""); sb.append(escapeForXML(student.getUsername())); sb.append("\" clickerid=\""); sb.append(escapeForXML(cidsDates[0])); sb.append("\" whenadded=\""); sb.append(escapeForXML(cidsDates[1])); sb.append("\" />\n"); } // close out sb.append("</courseenrollment>\n"); return sb.toString(); } public Gradebook decodeGradebookXML(String xml) { /* <coursegradebook courseid="BFW61"> <user id="lm_student01" usertype="S"> <lineitem name="06/02/2009" pointspossible="50" type="iclicker polling scores" score="0"/> </user> <user id="lm_student02" usertype="S"> <lineitem name="06/02/2009" pointspossible="50" type="iclicker polling scores" score="0"/> </user> </coursegradebook> */ if (xml == null || "".equals(xml)) { throw new IllegalArgumentException("xml must be set"); } // read the xml (try to anyway) DocumentBuilder db; try { db = DocumentBuilderFactory.newInstance().newDocumentBuilder(); } catch (ParserConfigurationException e) { e.printStackTrace(); throw new RuntimeException("XML parser failure: " + e, e); } Document doc; try { doc = db.parse(new ByteArrayInputStream(xml.getBytes())); } catch (SAXException e) { e.printStackTrace(); throw new RuntimeException("XML read failure: " + e, e); } catch (IOException e) { e.printStackTrace(); throw new RuntimeException("XML IO failure: " + e, e); } Gradebook gb; try { doc.getDocumentElement().normalize(); // get the course id from the root attribute String courseId = doc.getDocumentElement().getAttribute("courseid"); if (courseId == null || "".equals(courseId)) { throw new IllegalArgumentException("Invalid XML, no courseid in the root xml element"); } NodeList users = doc.getElementsByTagName("user"); if (users.getLength() == 0) { throw new IllegalArgumentException("Invalid XML, no user elements element"); } gb = new Gradebook(courseId); gb.courseId = courseId; for (int i = 0; i < users.getLength(); i++) { Node userNode = users.item(i); if (userNode.getNodeType() == Node.ELEMENT_NODE) { Element user = (Element) userNode; String userType = user.getAttribute("usertype"); if (!"s".equalsIgnoreCase(userType)) { continue; // skip this one } // valid user to process String userId = user.getAttribute("id"); // this is the userId if (userId == null || "".equals(userId)) { log.warn("Invalid XML for user, no id in the user element (skipping this entry): " + user); continue; } NodeList lineitems = user.getElementsByTagName("lineitem"); for (int j = 0; j < lineitems.getLength(); j++) { Element lineitem = (Element) lineitems.item(j); String liName = lineitem.getAttribute("name"); if (liName == null || "".equals(liName)) { throw new IllegalArgumentException( "Invalid XML, no name in the lineitem xml element: " + lineitem); } String liType = lineitem.getAttribute("type"); Double liPointsPossible = 100.0; String liPPText = lineitem.getAttribute("pointspossible"); if (liPPText != null && !"".equals(liPPText)) { try { liPointsPossible = Double.valueOf(liPPText); } catch (NumberFormatException e) { log.warn("Invalid points possible (" + liPPText + "), using default of " + liPointsPossible + ": " + lineitem + ": " + e); } } String liScore = lineitem.getAttribute("score"); if (liScore == null || "".equals(liScore)) { log.warn("Invalid score (" + liScore + "), skipping this entry: " + lineitem); continue; } GradebookItem gbi = new GradebookItem(gb.id, liName); if (!gb.items.contains(gbi)) { gbi.pointsPossible = liPointsPossible; gbi.type = liType; gb.items.add(gbi); } else { int pos = gb.items.lastIndexOf(gbi); if (pos >= 0) { gbi = gb.items.get(pos); } } // add in the score GradebookItemScore gbis = new GradebookItemScore(gbi.name, userId, liScore); gbi.scores.add(gbis); } } else { throw new IllegalArgumentException("Invalid user node in XML: " + userNode); } } } catch (DOMException e) { e.printStackTrace(); throw new RuntimeException("XML DOM parsing failure: " + e, e); } return gb; } public String encodeSaveGradebookResults(String courseId, List<GradebookItem> items) { if (courseId == null) { throw new IllegalArgumentException("courseId must be set"); } if (items == null) { throw new IllegalArgumentException("items must be set"); } // check for any errors boolean hasErrors = false; for (GradebookItem gbItem : items) { if (gbItem.scoreErrors != null && !gbItem.scoreErrors.isEmpty()) { hasErrors = true; break; } } // escapeForXML() /* SAMPLE <errors courseid="BFW61"> <Userdoesnotexisterrors> <user id="student03" /> </Userdoesnotexisterrors> <Scoreupdateerrors> <user id="student02"> <lineitem name="Decsample" pointspossible="0" type="Text" score="9" /> </user> </Scoreupdateerrors> <PointsPossibleupdateerrors> <user id="6367a431-557c-4869-88a7-229c2398f6ec"> <lineitem name="CMSIntTEST01" pointspossible="50" type="iclicker polling scores" score="70" /> </user> </PointsPossibleupdateerrors> <Scoreupdateerrors> <user id="iclicker_student01"> <lineitem name="Mac-integrate-2" pointspossible="31" type="092509Mac" score="13"/> </user> </Scoreupdateerrors> <Generalerrors> <user id="student02" error="CODE"> <lineitem name="itemName" pointspossible="35" score="XX" error="CODE" /> </user> </Generalerrors> </errors> */ String output = null; if (hasErrors) { Map<String, String> lineitems = makeLineitemsMap(items); HashSet<String> invalidUserIds = new HashSet<String>(); StringBuilder sb = new StringBuilder(); sb.append("<errors courseId=\""); sb.append(escapeForXML(courseId)); sb.append("\">\n"); // loop through items and errors and generate errors xml blocks Map<String, StringBuilder> errorItems = new LinkedHashMap<String, StringBuilder>(); for (GradebookItem gbItem : items) { if (gbItem.scoreErrors != null && !gbItem.scoreErrors.isEmpty()) { for (GradebookItemScore score : gbItem.scores) { if (score.error != null) { String lineitem = lineitems.get(gbItem.name); if (AbstractExternalLogic.USER_DOES_NOT_EXIST_ERROR.equals(score.error)) { String key = AbstractExternalLogic.USER_DOES_NOT_EXIST_ERROR; if (invalidUserIds.add(score.userId)) { // only if the invalid user is not already listed in the errors if (!errorItems.containsKey(key)) { errorItems.put(key, new StringBuilder()); } @SuppressWarnings("MismatchedQueryAndUpdateOfStringBuilder") StringBuilder sbe = errorItems.get(key); sbe.append(" <user id=\"").append(score.userId).append("\" />\n"); } } else if (AbstractExternalLogic.POINTS_POSSIBLE_UPDATE_ERRORS.equals(score.error)) { String key = AbstractExternalLogic.POINTS_POSSIBLE_UPDATE_ERRORS; if (!errorItems.containsKey(key)) { errorItems.put(key, new StringBuilder()); } @SuppressWarnings("MismatchedQueryAndUpdateOfStringBuilder") StringBuilder sbe = errorItems.get(key); String li = lineitem.replace(SCORE_KEY, score.grade); sbe.append(" <user id=\"").append(score.userId).append("\">\n").append(" ") .append(li).append("\n").append(" </user>\n"); } else if (AbstractExternalLogic.SCORE_UPDATE_ERRORS.equals(score.error)) { String key = AbstractExternalLogic.SCORE_UPDATE_ERRORS; if (!errorItems.containsKey(key)) { errorItems.put(key, new StringBuilder()); } @SuppressWarnings("MismatchedQueryAndUpdateOfStringBuilder") StringBuilder sbe = errorItems.get(key); String li = lineitem.replace(SCORE_KEY, score.grade); sbe.append(" <user id=\"").append(score.userId).append("\">\n").append(" ") .append(li).append("\n").append(" </user>\n"); } else { // general error String key = AbstractExternalLogic.GENERAL_ERRORS; if (!errorItems.containsKey(key)) { errorItems.put(key, new StringBuilder()); } @SuppressWarnings("MismatchedQueryAndUpdateOfStringBuilder") StringBuilder sbe = errorItems.get(key); String li = lineitem.replace(SCORE_KEY, score.grade); sbe.append(" <user id=\"").append(score.userId).append("\" error=\"") .append(score.error).append("\">\n").append(" <error type=\"") .append(score.error).append("\" />\n").append(" ").append(li) .append("\n").append(" </user>\n"); } } } } } // loop through error items and dump to the output if (errorItems.containsKey(AbstractExternalLogic.USER_DOES_NOT_EXIST_ERROR)) { sb.append(" <Userdoesnotexisterrors>\n"); sb.append(errorItems.get(AbstractExternalLogic.USER_DOES_NOT_EXIST_ERROR)); sb.append(" </Userdoesnotexisterrors>\n"); } if (errorItems.containsKey(AbstractExternalLogic.POINTS_POSSIBLE_UPDATE_ERRORS)) { sb.append(" <PointsPossibleupdateerrors>\n"); sb.append(errorItems.get(AbstractExternalLogic.POINTS_POSSIBLE_UPDATE_ERRORS)); sb.append(" </PointsPossibleupdateerrors>\n"); } if (errorItems.containsKey(AbstractExternalLogic.SCORE_UPDATE_ERRORS)) { sb.append(" <Scoreupdateerrors>\n"); sb.append(errorItems.get(AbstractExternalLogic.SCORE_UPDATE_ERRORS)); sb.append(" </Scoreupdateerrors>\n"); } if (errorItems.containsKey(AbstractExternalLogic.GENERAL_ERRORS)) { sb.append(" <Generalerrors>\n"); sb.append(errorItems.get(AbstractExternalLogic.GENERAL_ERRORS)); sb.append(" </Generalerrors>\n"); } // close out sb.append("</errors>\n"); output = sb.toString(); } return output; } protected String[] makeClickerIdsAndDates(Collection<ClickerRegistration> regs) { String clickerIds = ""; String clickerAddedDates = ""; if (regs != null) { DateFormat df = new SimpleDateFormat("MMM/dd/yyyy"); StringBuilder cids = new StringBuilder(); StringBuilder cads = new StringBuilder(); int count = 0; for (ClickerRegistration registration : regs) { if (count > 0) { cids.append(","); cads.append(","); } String clickerId = registration.getClickerId(); String clickerDate = df.format(registration.getDateCreated()); cids.append(clickerId); cads.append(clickerDate); count++; if (!disableAlternateRemoteID) { // add in the alternate clicker id if needed String alternateId = translateClickerId(clickerId); if (alternateId != null) { cids.append(","); cads.append(","); cids.append(alternateId); cads.append(clickerDate); count++; } } } clickerIds = cids.toString(); clickerAddedDates = cads.toString(); } return new String[] { clickerIds, clickerAddedDates }; } /* ************************************************************************ * Clicker ID validation * ************************************************************************ */ ThreadLocal<String> lastValidGOKey = new ThreadLocal<String>(); public static String CLICKERID_SAMPLE = "11A4C277"; /** * Cleans up and validates a given clickerId * @param clickerId a remote clicker ID * @return the cleaned up and valid clicker ID * @throws ClickerIdInvalidException if the id is invalid for some reason, * the exception will indicate the type of validation failure */ public String validateClickerId(String clickerId) { return validateClickerId(clickerId, null); } /** * Cleans up and validates a given clickerId * @param clickerId a remote clicker ID * @param lastName OPTIONAL user lastname (only used for GO checks), defaults to current user * @return the cleaned up and valid clicker ID * @throws ClickerIdInvalidException if the id is invalid for some reason, * the exception will indicate the type of validation failure */ public String validateClickerId(String clickerId, String lastName) { if (clickerId == null || "".equals(clickerId)) { throw new ClickerIdInvalidException("empty or null clickerId", Failure.EMPTY, clickerId); } int clickerIdLength = clickerId.length(); if (clickerIdLength == 12) { // support for new clicker go ids clickerId = clickerId.trim().toUpperCase(); if (!clickerId.matches("[0-9A-Z]+")) { throw new ClickerIdInvalidException("clickerId can only contain A-Z and 0-9", Failure.GO_CHARS, clickerId); } if (StringUtils.isEmpty(lastName)) { if (this.externalLogic.getCurrentUserId() == null) { throw new ClickerIdInvalidException( "No current user available, cannot validate GO clickerid: " + clickerId, Failure.GO_NO_USER, clickerId); } User u = this.externalLogic.getUser(this.externalLogic.getCurrentUserId()); lastName = u.lname; } // store the validated clicker to avoid checking the WS repeatedly in a single request String currentGOKey = clickerId + ":" + lastName; String lastValidKey = this.lastValidGOKey.get(); if (!currentGOKey.equals(lastValidKey)) { this.lastValidGOKey.remove(); wsGoVerifyId(clickerId, lastName); // ClickerIdInvalidException exception if invalid (or RT exception) this.lastValidGOKey.set(currentGOKey); } } else if (clickerIdLength <= 8) { // remote ids clickerId = clickerId.trim().toUpperCase(); if (!clickerId.matches("[0-9A-F]+")) { throw new ClickerIdInvalidException("clickerId can only contains A-F and 0-9", Failure.CHARS, clickerId); } while (clickerId.length() < 8) { clickerId = "0" + clickerId; } if (CLICKERID_SAMPLE.equals(clickerId)) { throw new ClickerIdInvalidException("clickerId cannot match the sample ID", Failure.SAMPLE, clickerId); } String[] idArray = new String[4]; idArray[0] = clickerId.substring(0, 2); idArray[1] = clickerId.substring(2, 4); idArray[2] = clickerId.substring(4, 6); idArray[3] = clickerId.substring(6, 8); int checksum = 0; for (String piece : idArray) { int hex = Integer.parseInt(piece, 16); checksum = checksum ^ hex; } if (checksum != 0) { throw new ClickerIdInvalidException("clickerId checksum (" + checksum + ") validation failed", Failure.CHECKSUM, clickerId); } } else { // totally invalid clicker length this.lastValidGOKey.remove(); throw new ClickerIdInvalidException( "clicker_id is an invalid length (" + clickerIdLength + "), must be 8 or 12 chars", Failure.LENGTH, clickerId); } return clickerId; } /** * For all remoteids starting with 2, 4, 8 we have to generate an alternate id * and concatenate it with the existing remote ids for that particular user in the data * sent to the iclicker desktop app (this is like creating an extra clickerid based on the existing ones) * * @param clickerId a remote clicker ID * @return a translated clicker ID OR null if no translation is required or id is invalid */ public String translateClickerId(String clickerId) { String alternateId = null; try { // validate the input, do nothing but return null if invalid clickerId = validateClickerId(clickerId, null); if (clickerId.length() == 8) { char startsWith = clickerId.charAt(0); if ('2' == startsWith || '4' == startsWith || '8' == startsWith) { // found clicker to translate int p1 = Integer.parseInt("0" + clickerId.charAt(1), 16); int p2 = Integer.parseInt(clickerId.substring(2, 4), 16); int p3 = Integer.parseInt(clickerId.substring(4, 6), 16); int p4 = p1 ^ p2 ^ p3; String part4 = Integer.toHexString(p4).toUpperCase(); if (part4.length() == 1) { part4 = "0" + part4; } alternateId = "0" + clickerId.substring(1, 6) + part4; } } } catch (ClickerIdInvalidException e) { alternateId = null; } return alternateId; } // GO WEBSERVICES /** * Verify a clicker id with the GO webservices server, * Returns true on success OR ClickerIdInvalidException on failure * * @param clickerGOId 12 char clicker go id * @param studentLastName user last name * @return bool true if the clicker id is valid and linked to the provided user lastname (or throws exception) * @throws ClickerIdInvalidException (GO_LASTNAME) if the lastname does not match OR GO_NO_MATCH if it does not match * @throws RuntimeException if the format does not match or a failure occurs * @throws IllegalArgumentException if the params are not set */ public boolean wsGoVerifyId(String clickerGOId, String studentLastName) { if (clickerGOId == null || studentLastName == null) { throw new IllegalArgumentException( "clickerGOId=" + clickerGOId + " and studentLastName=" + studentLastName + " must both be set"); } String resultXML; String url = "https://www.iclickergo.com/webservice/webvoting.asmx"; String encodedClickerId = Base64.encodeBase64String(clickerGOId.getBytes()); try { Map<String, String> headers = new HashMap<String, String>(); headers.put("Host", "www.iclickergo.com"); headers.put("Content-Type", "application/soap+xml; charset=utf-8"); String xml = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" + "<soap12:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" xmlns:soap12=\"http://www.w3.org/2003/05/soap-envelope\">\n" + " <soap12:Body>\n" + " <GetRegisteredForClickerMAC xmlns=\"http://www.iclicker.com/\">\n" + " <pVarClickerID>" + encodedClickerId + "</pVarClickerID>\n" + " </GetRegisteredForClickerMAC>\n" + " </soap12:Body>\n" + "</soap12:Envelope>"; HttpResponse response = HttpRESTUtils.fireRequest(url, HttpRESTUtils.Method.POST, null, headers, xml, true); resultXML = getXMLAndCheckResponse(response, "<GetRegisteredForClickerMACResult>", "</GetRegisteredForClickerMACResult>", "<GetRegisteredForClickerMACResult />"); resultXML = StringUtils.trimToNull(resultXML); } catch (Exception e) { throw new RuntimeException( "Failed to post verify id (" + clickerGOId + ") to webservice (" + url + "): " + e); } if (resultXML == null) { // no registration matches throw new ClickerIdInvalidException("No match found on the server for clicker: " + clickerGOId, Failure.GO_NO_MATCH, clickerGOId); } else { // <StudentEnrol><S StudentId="testgoqait99" FirstName="testgoqait99" LastName="testgoqait99" MiddleName="" WebClickerId="C570BF0C2154"/></StudentEnrol> resultXML = new String(Base64.decodeBase64(resultXML)); if (StringUtils.contains(resultXML, "StudentEnrol") && StringUtils.contains(resultXML, "LastName")) { Map<String, String> data = decodeGetRegisteredForClickerMACResult(resultXML); String lastName = data.get("LastName"); // StudentEnrol->LastName; boolean verified = studentLastName.equalsIgnoreCase(lastName); if (!verified) { // should we log a warning here? -AZ throw new ClickerIdInvalidException( "Lastname (" + studentLastName + ") does not match with registered lastname (" + lastName + ") for clicker (" + clickerGOId + ")", Failure.GO_LASTNAME, clickerGOId); } } else { // structure did not properly match String msg = "i>clicker Webservices return structure does not match expected format (please contact support): " + resultXML; log.error(msg); throw new RuntimeException(msg); } } return true; } /** * @param response the http response object * @param start the start delimiter of the content * @param end the end delimiter of the content * @param emptyIndicator [OPTIONAL] if set and is contained in the body then returns empty result * @return the body of the response between the start and end strings if there is one * @throws IllegalStateException if the webservices fail or the response is invalid */ private String getXMLAndCheckResponse(HttpResponse response, String start, String end, String emptyIndicator) { if (response.getResponseCode() >= 400) { String msg = "i>clicker Webservices failure: bad response code (" + response.responseCode + "), message: " + response.responseMessage; log.error(msg); throw new IllegalStateException(msg); } String xml; String body = response.getResponseBody(); if (body != null) { body = body.trim(); if (emptyIndicator != null && StringUtils.contains(body, emptyIndicator)) { xml = ""; } else { String result = StringUtils.substringBetween(body, start, end); if (result == null) { String msg = "i>clicker Webservices failure: (" + response.responseCode + "), message:" + response.responseMessage + " : body=" + body; log.error(msg); throw new IllegalStateException(msg); } xml = StringEscapeUtils.unescapeXml(result); if (xml.startsWith("<RetStatus")) { String msg = "i>clicker Webservices failure: (" + response.responseCode + "), message:" + response.responseMessage + " : xml=" + xml; log.error(msg); throw new IllegalStateException(msg); } } } else { String msg = "i>clicker Webservices failure: no response body from server: (" + response.responseCode + "), message:" + response.responseMessage; log.error(msg); throw new IllegalStateException(msg); } return xml; } /** * Attempt to decode the XML into readable values in a map * * @param xml XML * @return the map of attributes from S student record * @throws IllegalArgumentException if the xml is invalid or blank * @throws RuntimeException if there is an internal failure in the XML parser */ public Map<String, String> decodeGetRegisteredForClickerMACResult(String xml) { /* <StudentEnrol> <S StudentId="testgoqait99" FirstName="testgoqait99" LastName="testgoqait99" MiddleName="" WebClickerId="C570BF0C2154"/> </StudentEnrol> */ if (xml == null || "".equals(xml)) { throw new IllegalArgumentException("xml must be set"); } // read the xml (try to anyway) DocumentBuilder db; try { db = DocumentBuilderFactory.newInstance().newDocumentBuilder(); } catch (ParserConfigurationException e) { e.printStackTrace(); throw new RuntimeException("XML parser failure: " + e, e); } Document doc; try { doc = db.parse(new ByteArrayInputStream(xml.getBytes())); } catch (SAXException e) { e.printStackTrace(); throw new RuntimeException("XML read failure: " + e, e); } catch (IOException e) { e.printStackTrace(); throw new RuntimeException("XML IO failure: " + e, e); } HashMap<String, String> m = new HashMap<String, String>(); try { doc.getDocumentElement().normalize(); NodeList users = doc.getElementsByTagName("S"); if (users.getLength() == 0) { throw new IllegalArgumentException("Invalid XML, no S element"); } Node userNode = users.item(0); if (userNode.getNodeType() == Node.ELEMENT_NODE) { Element user = (Element) userNode; NamedNodeMap attributes = user.getAttributes(); for (int j = 0; j < attributes.getLength(); j++) { String name = attributes.item(j).getNodeName(); String value = attributes.item(j).getNodeValue(); m.put(name, value); } } else { throw new IllegalArgumentException("Invalid user node in XML: " + userNode); } } catch (DOMException e) { e.printStackTrace(); throw new RuntimeException("XML DOM parsing failure: " + e, e); } return m; } public static final char AMP = '&'; public static final char APOS = '\''; public static final char GT = '>'; public static final char LT = '<'; public static final char QUOT = '"'; /** * Escape a string for XML encoding: replace special characters with XML escapes * <br/> * &, <, >, ", ' will be escaped * * @param string The string to be escaped. * @return The escaped string. */ public static String escapeForXML(String string) { StringBuilder sb = new StringBuilder(); for (int i = 0, len = string.length(); i < len; i++) { char c = string.charAt(i); switch (c) { case AMP: sb.append("&"); break; case LT: sb.append("<"); break; case GT: sb.append(">"); break; case QUOT: sb.append("""); break; case APOS: sb.append("'"); break; default: sb.append(c); } } return sb.toString(); } protected static IClickerLogic instance; public static IClickerLogic getInstance() { return IClickerLogic.instance; } protected static void setInstance(IClickerLogic instance) { IClickerLogic.instance = instance; } // SPRING setters /* UNUSED public void setUseNationalServices(Boolean useNationalServices) { // nothing }*/ public void setDao(IClickerDao dao) { this.dao = dao; } public void setExternalLogic(ExternalLogic externalLogic) { this.externalLogic = externalLogic; } public ExternalLogic getExternalLogic() { return externalLogic; } public void setMessageSource(ReloadableResourceBundleMessageSource messageSource) { this.messageSource = messageSource; } }