From ac785a3dfd10a9499c70f905ef4d3c2554b8c7b1 Mon Sep 17 00:00:00 2001 From: Chad Phillips Date: Sat, 26 Mar 2016 21:50:27 -0600 Subject: [PATCH 1/6] event templates functionality, for sessions and session messages. provides the ability to pre-configure sessions and session messages via templates hosted on google spreadsheets, which can then be used for quick session creation and message sending. --- lib/server-models.js | 18 ++++ lib/unhangout-db.js | 18 +++- lib/unhangout-routes.js | 186 +++++++++++++++++++++++++++++++++++++- lib/unhangout-server.js | 2 + package.json | 2 + public/js/event-app.js | 4 +- public/js/event-views.js | 100 +++++++++++++++++++- public/js/models.js | 29 ++++++ views/_identity.ejs | 6 +- views/event-templates.ejs | 117 ++++++++++++++++++++++++ views/event.ejs | 90 +++++++++++++++++- 11 files changed, 565 insertions(+), 7 deletions(-) create mode 100644 views/event-templates.ejs diff --git a/lib/server-models.js b/lib/server-models.js index 16158b09..f3f686fc 100644 --- a/lib/server-models.js +++ b/lib/server-models.js @@ -979,3 +979,21 @@ exports.ServerChatList = client_models.ChatMessageList.extend({ } }); +exports.ServerEventSessionTemplate = client_models.EventSessionTemplate.extend({ + idRoot: "event-session-template", + urlRoot: "event-session-template", +}); + +exports.ServerEventSessionTemplateList = client_models.EventSessionTemplateList.extend({ + model: exports.ServerEventSessionTemplate, +}); + +exports.ServerEventSessionMessageTemplate = client_models.EventSessionMessageTemplate.extend({ + idRoot: "event-session-message-template", + urlRoot: "event-session-message-template", +}); + +exports.ServerEventSessionMessageTemplateList = client_models.EventSessionMessageTemplateList.extend({ + model: exports.ServerEventSessionMessageTemplate, +}); + diff --git a/lib/unhangout-db.js b/lib/unhangout-db.js index 82bedbcb..e7ac9094 100644 --- a/lib/unhangout-db.js +++ b/lib/unhangout-db.js @@ -23,6 +23,8 @@ _.extend(UnhangoutDb.prototype, events.EventEmitter.prototype, { this.users.comparator = undefined; this.events = new models.ServerEventList(); this.permalinkSessions = new models.ServerSessionList(); + this.eventSessionTemplates = new models.ServerEventSessionTemplateList(); + this.eventSessionMessageTemplates = new models.ServerEventSessionMessageTemplateList(); // Users keep a cache of events they admin. Update this cache on any // relevant changes to events. @@ -82,6 +84,8 @@ _.extend(UnhangoutDb.prototype, events.EventEmitter.prototype, { counter += event.get("sessions").length; }); logger.info("Loaded " + counter + " sessions from redis."); + logger.info("loaded " + this.eventSessionTemplates.length + " event session templates from redis."); + logger.info("loaded " + this.eventSessionMessageTemplates.length + " event session message templates from redis."); this.emit("inited"); } callback && callback(err); @@ -154,7 +158,19 @@ _.extend(UnhangoutDb.prototype, events.EventEmitter.prototype, { newSession.onRestart(); callback(); - }] + }], + + ["event-session-template/*", function(callback, attrs, key) { + var newEventSessionTemplate = new models.ServerEventSessionTemplate(attrs); + that.eventSessionTemplates.add(newEventSessionTemplate, {sort: false}); + callback(); + }], + + ["event-session-message-template/*", function(callback, attrs, key) { + var newEventSessionMessageTemplate = new models.ServerEventSessionMessageTemplate(attrs); + that.eventSessionMessageTemplates.add(newEventSessionMessageTemplate, {sort: false}); + callback(); + }], ]; // This mess is doing three things: // 1) figuring out all the key names of all the objects of this type in redis diff --git a/lib/unhangout-routes.js b/lib/unhangout-routes.js index 4ff59c9a..fc10a99c 100644 --- a/lib/unhangout-routes.js +++ b/lib/unhangout-routes.js @@ -7,7 +7,10 @@ var _ = require("underscore"), googleapis = require('googleapis'), moment = require("moment-timezone"), mandrill = require("mandrill-api"), - async = require("async"); + async = require("async"), + request = require("request"), + util = require("util"), + extractYoutubeId = require("../public/js/extract-youtube-id"); var ensureAuthenticated = utils.ensureAuthenticated; @@ -43,6 +46,183 @@ module.exports = { }); }); + app.get("/event-templates/", utils.ensureAdmin, function(req, res) { + var sessionTemplatesKey = options.UNHANGOUT_SESSION_TEMPLATES_SPREADSHEET; + var sessionMessageTemplatesKey = options.UNHANGOUT_SESSION_MESSAGE_TEMPLATES_SPREADSHEET; + res.render('event-templates.ejs', { + user: req.user, + sessionTemplates: db.eventSessionTemplates, + sessionTemplatesKey: sessionTemplatesKey, + sessionMessageTemplates: db.eventSessionMessageTemplates, + sessionMessageTemplatesKey: sessionMessageTemplatesKey, + }); + }); + + app.get("/event-templates/reload/", utils.ensureSuperuser, function(req, res) { + var sessionTemplatesKey = options.UNHANGOUT_SESSION_TEMPLATES_SPREADSHEET; + var sessionMessageTemplatesKey = options.UNHANGOUT_SESSION_MESSAGE_TEMPLATES_SPREADSHEET; + var spreadsheetDataUrl = "https://spreadsheets.google.com/feeds/cells/%s/default/public/basic?alt=json"; + + var parseSpreadsheet = function(mapping, data) { + var entry,i,cell,col; + var rows = [] + + for (var i = 0; i < data.feed.entry.length; i++) { + cell = data.feed.entry[i]; + col = cell.title.$t.substring(0, 1); + if (col === 'A') { + entry = {}; + rows.push(entry); + } + entry[mapping[col]] = cell.content.$t.trim(); + } + // Drop the first row which has the headings. + rows = rows.slice(1); + return rows; + }; + + var fetchTemplatesFunction = function(spreadsheetKey, mapping) { + if (_.isUndefined(spreadsheetKey)) { + // Return an empty array if no key exists. + return function(cb) { cb(null, []); } + } + else { + return function(callback) { + var url = util.format(spreadsheetDataUrl, spreadsheetKey); + var result = function(error, response, json) { + if (!error && response.statusCode == 200) { + rows = parseSpreadsheet(mapping, json); + callback(null, rows); + } + else { + callback(error); + } + } + var requestParams = { + url: url, + json: true, + }; + request(requestParams, result); + } + } + }; + + var fetchSessionTemplates = function() { + var mapping = { + "A": "title", + "B": "limit", + "C": "type", + "D": "url", + "E": "description" + }; + return fetchTemplatesFunction(sessionTemplatesKey, mapping); + }; + + var fetchSessionMessageTemplates = function() { + var mapping = { + "A": "title", + "B": "message", + }; + return fetchTemplatesFunction(sessionMessageTemplatesKey, mapping); + }; + + var validateTemplates = function(data) { + var errors = []; + for (var i=0; i < data.sessionTemplates.length; i++) { + var template = data.sessionTemplates[i]; + var leader = util.format("Session template %s: ", i + 1); + var limit = parseInt(template.limit); + if (_.isNaN(limit) || limit < 2 || limit > 10) { + errors.push(leader + "Participant limit must be a number between 2 and 10"); + } + switch (template.type) { + // No validation needed for this type. + case "simple": + break; + case "video": + template.ytid = extractYoutubeId.extractYoutubeId(template.url); + if (!template.ytid) { + errors.push(leader + "Unrecognized youtube URL. Use the URL for a single video."); + } + break; + case "webpage": + if (!/^https:\/\//.test(template.url)) { + errors.push(leader + "Only secure pages (those starting with 'https') can be embedded in hangouts."); + } + break; + default: + errors.push(leader + "Session type must be one of: simple, video, webpage"); + } + } + + for (var i=0; i < data.sessionMessageTemplates.length; i++) { + var template = data.sessionMessageTemplates[i]; + var leader = util.format("Session message template %s: ", i + 1); + // This doesn't seem to be triggered, guessing if the + // first cell in a spreadsheet is blank, it's not + // included in the feed. Leaving here for defensive + // programming. + if (_.isEmpty(template.title)) { + errors.push(leader + "Title cannot be empty"); + } + if (_.isEmpty(template.message)) { + errors.push(leader + "Message cannot be empty"); + } + } + if (_.isEmpty(errors)) { + saveTemplates(data); + } + else { + _.each(errors, function(e) { + req.flash("danger", e); + }); + return res.redirect("event-templates"); + } + } + + var saveTemplates = function(data) { + // Clear out the old templates, the new set is + // saved fresh. + _.each(_.clone(db.eventSessionTemplates.models), function(model) { + logger.debug("Removing '" + model.get("title") + "' from session templates..."); + model.destroy(); + }); + _.each(_.clone(db.eventSessionMessageTemplates.models), function(model) { + logger.debug("Removing '" + model.get("title") + "' from session message templates..."); + model.destroy(); + }); + _.each(data.sessionTemplates, function(template) { + var newModel = new db.eventSessionTemplates.model(template); + logger.debug("Saving '" + newModel.get("title") + "' to session templates..."); + newModel.save(); + db.eventSessionTemplates.add(newModel); + }); + _.each(data.sessionMessageTemplates, function(template) { + var newModel = new db.eventSessionMessageTemplates.model(template); + logger.debug("Saving '" + newModel.get("title") + "' to session message templates..."); + newModel.save(); + db.eventSessionMessageTemplates.add(newModel); + }); + req.flash("success", "Templates reloaded."); + return res.redirect("event-templates"); + } + + var templatesFetched = function(err, data) { + if (err) { + return res.send(500, util.format('ERROR: %s', err)); + } + else { + validateTemplates(data); + } + } + + var templateFetchFunctions = { + sessionTemplates: fetchSessionTemplates(), + sessionMessageTemplates: fetchSessionMessageTemplates(), + }; + async.parallel(templateFetchFunctions, templatesFetched); + }); + app.get("/about/", function(req, res) { res.render('about.ejs', {user: req.user}); }); @@ -73,7 +253,9 @@ module.exports = { title: event.get("title"), nodeEnv: process.env.NODE_ENV, numHangoutUrlsAvailable: farming.getNumHangoutsAvailable(), - numHangoutUrlsWarning: options.UNHANGOUT_HANGOUT_URLS_WARNING + numHangoutUrlsWarning: options.UNHANGOUT_HANGOUT_URLS_WARNING, + eventSessionTemplates: db.eventSessionTemplates, + eventSessionMessageTemplates: db.eventSessionMessageTemplates, }; var template; diff --git a/lib/unhangout-server.js b/lib/unhangout-server.js index 45521a3c..ad10bf28 100644 --- a/lib/unhangout-server.js +++ b/lib/unhangout-server.js @@ -26,6 +26,7 @@ var logging = require('./logging'), fs = require('fs'), memwatch = require("memwatch"), slashes = require("connect-slashes"); + flash = require("flash"); // This is the primary class that represents the UnhangoutServer. // I organize the server pieces into a class so we can more easily @@ -199,6 +200,7 @@ exports.UnhangoutServer.prototype = { store: redisSessionStore, cookie: {maxAge:1000*60*60*24*2} })); + this.app.use(flash()); if (this.options.mockAuth) { var mockPassport = require("./passport-mock"); diff --git a/package.json b/package.json index 7b710ab8..6c75485d 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "deep-copy": "^1.0.0", "ejs": "1.0.0", "express": "3.5.1", + "flash": "1.1.0", "gapitoken": "0.1.3", "googleapis": "0.7.0", "mandrill-api": "1.0.41", @@ -56,6 +57,7 @@ "passport": "0.2.0", "passport-google-oauth": "0.1.5", "redis": "0.10.1", + "request": "2.55.0", "requirejs-middleware": "git://github.com/yourcelf/requirejs-middleware.git#master", "sockjs": "0.3.8", "stylus": "0.42.3", diff --git a/public/js/event-app.js b/public/js/event-app.js index 86e5b36e..4fe7f0d1 100644 --- a/public/js/event-app.js +++ b/public/js/event-app.js @@ -142,7 +142,9 @@ $(document).ready(function() { model: curEvent, transport: trans }); this.dialogView = new eventViews.DialogView({ - event: curEvent, transport: trans + event: curEvent, + transport: trans, + eventSessionTemplates: new models.EventSessionTemplateList(EVENT_SESSION_TEMPLATES), }); this.aboutView = new eventViews.AboutEventView({model: curEvent}); diff --git a/public/js/event-views.js b/public/js/event-views.js index 437e3b64..2e1b2b81 100644 --- a/public/js/event-views.js +++ b/public/js/event-views.js @@ -611,8 +611,11 @@ views.DialogView = Backbone.Marionette.Layout.extend({ 'click #send-email-button': 'sendFollowupEmail', 'click #submit-contact-info': 'submitContactInfo', 'click #btn-propose-session': 'proposeSessionDialog', - 'click #propose': 'proposeSession', + 'click #propose': 'proposeSession', 'input .input-topic-title': 'fillTopicPreview', + 'click #create-session-from-template':'createSessionFromTemplate', + 'change #session_template_select': 'updateSessionTemplateForm', + 'change #session_message_template': 'updateSessionMessageFromTemplate' }, addUrlToSessionMessage: function(event) { @@ -884,7 +887,102 @@ views.DialogView = Backbone.Marionette.Layout.extend({ closeDisconnected: function() { $("#disconnected-modal").modal('hide'); + }, + + updateSessionMessageFromTemplate: function(event) { + event.preventDefault(); + var el = $("#message-sessions-modal textarea"); + var message = $("#session_message_template").val(); + el.val(message); + el.change(); + }, + updateSessionTemplateForm: function(event) { + event.preventDefault(); + var templateId = parseInt($("#session_template_select").val()); + var details = "" + if (!isNaN(templateId)) { + var template = this.options.eventSessionTemplates.get(templateId); + // NOTE: Bad form to put HTML here, and, good enough for now. + details += "# Participants: " + template.get("limit") + ", Session type: " + template.get("type"); + var url = template.get("url"); + if (!_.isEmpty(url)) { + details += ", URL: " + url + "" + } + var description = template.get("description"); + if (!_.isEmpty(description)) { + details += "
" + description + "
" + } + } + $("#session_template_select_details").html(details); + }, + + createSessionFromTemplate: function(event) { + event.preventDefault(); + var scope = $("#create-session-from-template-modal"); + var templateId = parseInt($("#session_template_select").val()); + var numSessions = parseInt($.trim($("#sessions_to_create").val())); + + if (isNaN(templateId)) { + $(".template-id-error", scope).show(); + return; + } + + if (isNaN(numSessions) || numSessions < 1) { + $(".num-sessions-error", scope).show(); + return; + } + + var template = this.options.eventSessionTemplates.get(templateId); + if (!template) { + $(".template-id-error", scope).show(); + return; + } + + var title = template.get("title"); + var joinCap = template.get("limit"); + var type = template.get("type"); + var url = template.get("url"); + var ytid = template.get("ytid"); + var roomId = this.options.event.getRoomId(); + + var activities = []; + switch (type) { + case "simple": + activities.push({type: "about", autoHide: true}); + break; + case "video": + activities.push({type: "video", video: {provider: "youtube", id: ytid}}); + break; + case "webpage": + activities.push({type: "webpage", url: url}); + break; + } + + var countTitle; + for (var i = 1; i <= numSessions; i++) { + // Number multiple sessions. + countTitle = numSessions === 1 ? title : title + " #" + i; + this.options.transport.send("create-session", { + title: countTitle, + description:"", + activities: activities, + joinCap: joinCap, + roomId: roomId, + approved: true, + }); + } + + // TODO: Is there some modal open event that can be used to do this + // more consistently? + $("#session_template_select").val(""); + $("#sessions_to_create").val("1"); + $(".template-id-error", scope).hide(); + $(".num-sessions-error", scope).hide(); + $("#session_template_select_details").html("# of participants, session type, and any URL will be listed here."); + $(".error", scope).removeClass(".error"); + scope.modal('hide'); } + }); // Generates the admin menu items. diff --git a/public/js/models.js b/public/js/models.js index 230604bc..2fdd75b1 100644 --- a/public/js/models.js +++ b/public/js/models.js @@ -588,6 +588,35 @@ models.ChatMessageList = Backbone.Collection.extend({ model:models.ChatMessage }); +models.EventSessionTemplate = Backbone.Model.extend({ + defaults: function() { + return { + title: "Session", + limit: "2", + type: "simple", + url: "", + description: "", + }; + }, +}); + +models.EventSessionTemplateList = Backbone.Collection.extend({ + model:models.EventSessionTemplate +}); + +models.EventSessionMessageTemplate = Backbone.Model.extend({ + defaults: function() { + return { + title: "Session message", + message: "", + }; + }, +}); + +models.EventSessionMessageTemplateList = Backbone.Collection.extend({ + model:models.EventSessionMessageTemplate +}); + return models; }); // End of define diff --git a/views/_identity.ejs b/views/_identity.ejs index 6307ed21..956b4cf2 100644 --- a/views/_identity.ejs +++ b/views/_identity.ejs @@ -24,7 +24,11 @@
  • My Events
  • - + <% if (user.isSuperuser()) { %> +
  • + Event templates +
  • + <% } %> <% } %> diff --git a/views/event-templates.ejs b/views/event-templates.ejs new file mode 100644 index 00000000..3b8a8a8e --- /dev/null +++ b/views/event-templates.ejs @@ -0,0 +1,117 @@ +<% include _header.ejs %> +<% include _navbar.ejs %> + +
    + + <% while (data = flash.shift()) { %> +
    + × + <% if (data.type == "danger") { %> + ERROR: + <% } %> + <%= data.message %> +
    + <% } %> + +

    Event templates - Reload templates

    + +
    +

    + Event templates allow an admin to pre-load various event-related + data for quick use in events. +

    +

    + The templates are read from specially formatted Google + spreadsheets. Each time the Reload templates link at + the top of this page is clicked, the spreadsheets are re-read, and + the template data available to events is reconfigured accordingly. +

    + + Notes: +
      +
    • + Only users with the superuser permission can reload + templates. +
    • +
    • + Open event windows in the browser must be refreshed + after the template reload operation in order for + admins to access the reloaded templates. +
    • +
    +
    +
    +

    +

    + To build templates for events, create a Google spreadsheet with the + listed columns, in order. Be sure to include column headers in the + first row. Publish the spreadsheet be selecting + File → Publish to the web... from the Google + Spreadsheet menu. Finally, set the value for the appropriate variable + in conf.json to the spreadsheet ID for the spreadsheet. +

    +

    + The currently available templates are described below, with the + necessary columns in order. +

    +

    + Column list for session templates: +

      +
    • Name of session (required)
    • +
    • # of allowed participants (required)
    • +
    • Session type (one of 'simple', 'video', 'webpage') (required)
    • +
    • URL for YouTube or webpage sessions (required for video and webpage types)
    • +
    • A longer description of the session for admin use
    • +
    +

    +

    + Column list for session message templates: +

      +
    • Name of message (required)
    • +
    • Message (required)
    • +
    +

    +
    +

    Currenly loaded templates

    + + <% if (sessionTemplates.length > 0) { %> +

    Sessions - edit spreadsheet

    + <% sessionTemplates.each(function(model) { %> + <% var title = model.get("title") %> + <% var limit = model.get("limit") %> + <% var type = model.get("type") %> + <% var url = model.get("url") %> + <% var description = model.get("description") %> +
    +
    <%= title %> (<%= limit %> participants)
    +
    + <% if (type === "simple") { %> + Type: <%= type %> + <% } else { %> + Type: <%= type %>, URL: <%= url %> + <% } %> +
    +
    + <%= description %> +
    +
    + <% }); %> + <% } %> + <% if (sessionMessageTemplates.length > 0) { %> +

    Session messages - edit spreadsheet

    + <% sessionMessageTemplates.each(function(model) { %> + <% var title = model.get("title") %> + <% var message = model.get("message") %> +
    +
    <%= title %>
    +
    + <%= message %> +
    +
    + <% }); %> + <% } %> +
    + +<%- requireScripts("/public/js/index.js") %> +<% include _sponsorship.ejs %> +<% include _analytics.ejs %> diff --git a/views/event.ejs b/views/event.ejs index de81292e..b0f5f0fb 100644 --- a/views/event.ejs +++ b/views/event.ejs @@ -15,6 +15,7 @@ var HOA_ATTRS = <%- event.get("hoa") && user.isAdminOf(event) ? JSON.stringify(event.get("hoa").toClientJSON()) : "null" %>; var CONNECTED_USERS = <%- JSON.stringify(event.get("connectedUsers").invoke("toClientJSON")) %> var RECENT_MESSAGES = <%- JSON.stringify(event.get("recentMessages")) %>; + var EVENT_SESSION_TEMPLATES = <%- JSON.stringify(eventSessionTemplates) %>; <% } %> @@ -612,9 +613,13 @@  Create Session + <% if (eventSessionTemplates.length > 0) { %> + +  Create Session From Template + + <% } %> - Edit Event Info @@ -666,6 +671,9 @@ $(".youtube-url").hide(); $(".webpage-url").hide(); $(".join-cap-error").hide(); + $(".template-id-error").hide(); + $(".num-sessions-error").hide(); + $("#session_template_select_details").html("# of participants, session type, and any URL will be listed here."); $('[data-toggle="tooltip"]').tooltip(); @@ -792,6 +800,70 @@ + + +