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/css/screen.styl b/public/css/screen.styl index 61d2510d..be064220 100644 --- a/public/css/screen.styl +++ b/public/css/screen.styl @@ -3146,6 +3146,21 @@ user-admin-border-size = 2px } } +/*-------------------------- EVENT TEMPLATES -------------------------------*/ + +.event-template-container { + margin-top: 30px; +} + +#create-session-from-template-modal { + #sessions_to_create { + width: 6em; + } + #total_connected_users_reference { + padding-top: 7px; + } +} + /* --------------------- IE OVERRIDES -----------------------*/ /* IE overrides */ 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..f702d436 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,128 @@ 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); + var limit = template.get("limit"); + // NOTE: Bad form to put HTML here, and, good enough for now. + details += "# Participants: " + 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 += "
+ 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.
+
+ 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: +
+ Column list for session message templates: +