From 6901b758e6e5836f19476e490f83cb85e57a5c27 Mon Sep 17 00:00:00 2001
From: Catsync <catsync@zdh.com>
Date: Thu, 16 Jun 2016 15:56:43 -0400
Subject: [PATCH 01/28] Update estimated course times (#3738)

* Update time estimates for courses.

* Update coming soon text.
---
 app/locale/en.coffee         | 2 +-
 app/views/NewHomeView.coffee | 6 +++---
 2 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/app/locale/en.coffee b/app/locale/en.coffee
index 3558d4236..29528035a 100644
--- a/app/locale/en.coffee
+++ b/app/locale/en.coffee
@@ -71,7 +71,7 @@
     curriculum: "Total curriculum hours:"
     ffa: "Free for all students"
     lesson_time: "Lesson time:"
-    coming_soon: "Coming soon!"
+    coming_soon: "Coming this fall!"
     courses_available_in: "Courses are available in JavaScript, Python, and Java (coming soon!)"
     boast: "Boasts riddles that are complex enough to fascinate gamers and coders alike."
     winning: "A winning combination of RPG gameplay and programming homework that pulls off making kid-friendly education legitimately enjoyable."
diff --git a/app/views/NewHomeView.coffee b/app/views/NewHomeView.coffee
index 3ccade336..d7bc6fda8 100644
--- a/app/views/NewHomeView.coffee
+++ b/app/views/NewHomeView.coffee
@@ -122,9 +122,9 @@ module.exports = class NewHomeView extends RootView
 
   onChangeSchoolLevelDropdown: (e) ->
     levels =
-      elementary: {'introduction-to-computer-science': '2-4', 'computer-science-5': '15-20', default: '10-15', total: '50-70 hours (about one year)'}
-      middle: {'introduction-to-computer-science': '1-3', 'computer-science-5': '7-10', default: '5-8', total: '25-35 hours (about one semester)'}
-      high: {'introduction-to-computer-science': '1', 'computer-science-5': '6-9', default: '5-6', total: '22-28 hours (about one semester)'}
+      elementary: {'introduction-to-computer-science': '2-4', 'computer-science-6': '24-30', 'computer-science-7': '30-40', 'computer-science-8': '30-40', default: '16-25', total: '150-215 hours (about two and a half years)'}
+      middle: {'introduction-to-computer-science': '1-3', 'computer-science-6': '12-14', 'computer-science-7': '14-16', 'computer-science-8': '14-16', default: '8-12', total: '75-100 hours (about one and a half years)'}
+      high: {'introduction-to-computer-science': '1', 'computer-science-6': '10-12', 'computer-science-7': '12-16', 'computer-science-8': '12-16', default: '8-10', total: '65-85 hours (about one year)'}
     level = if e then $(e.target).val() else 'middle'
     @$el.find('#courses-row .course-details').each ->
       slug = $(@).data('course-slug')

From 972c632d85d0f9664d12d08d015e0c6602d7bc8f Mon Sep 17 00:00:00 2001
From: Scott Erickson <sderickson@gmail.com>
Date: Thu, 16 Jun 2016 14:32:43 -0700
Subject: [PATCH 02/28] Fix fr.coffee indentation

---
 app/locale/fr.coffee | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/locale/fr.coffee b/app/locale/fr.coffee
index a4d27068c..14839df57 100644
--- a/app/locale/fr.coffee
+++ b/app/locale/fr.coffee
@@ -14,7 +14,7 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
     for_developers: "Pour développeurs" # Not currently shown on home page.
     or_ipad: "Ou télécharger pour iPad"
 
- new_home:
+  new_home:
     slogan: "Le jeu le plus engageant pour apprendre la programmation."
     classroom_edition: "Édition Classe:"
     learn_to_code: "Apprend à programmer:"

From ca83ed05e484239bdf3c2036f51f7cc07a8eb59d Mon Sep 17 00:00:00 2001
From: Scott Erickson <sderickson@gmail.com>
Date: Thu, 16 Jun 2016 16:00:45 -0700
Subject: [PATCH 03/28] Only require user sessions on /db requests that are not
 GET

---
 server/routes/index.coffee                 | 5 ++++-
 spec/server/functional/prepaid.spec.coffee | 2 +-
 2 files changed, 5 insertions(+), 2 deletions(-)

diff --git a/server/routes/index.coffee b/server/routes/index.coffee
index c523fcc3d..184ba069b 100644
--- a/server/routes/index.coffee
+++ b/server/routes/index.coffee
@@ -14,7 +14,10 @@ module.exports.setup = (app) ->
   app.get('/auth/unsubscribe', mw.auth.unsubscribe)
   app.get('/auth/whoami', mw.auth.whoAmI)
 
-  app.all('/db/*', mw.auth.checkHasUser())
+  app.delete('/db/*', mw.auth.checkHasUser())
+  app.patch('/db/*', mw.auth.checkHasUser())
+  app.post('/db/*', mw.auth.checkHasUser())
+  app.put('/db/*', mw.auth.checkHasUser())
   
   Achievement = require '../models/Achievement'
   app.get('/db/achievement', mw.achievements.fetchByRelated, mw.rest.get(Achievement))
diff --git a/spec/server/functional/prepaid.spec.coffee b/spec/server/functional/prepaid.spec.coffee
index 9aaf1fc6d..4baacae41 100644
--- a/spec/server/functional/prepaid.spec.coffee
+++ b/spec/server/functional/prepaid.spec.coffee
@@ -544,7 +544,7 @@ describe '/db/prepaid', ->
       logoutUser () ->
         fetchPrepaid joeCode, (err, res) ->
           expect(err).toBeNull()
-          expect(res.statusCode).toEqual(401)
+          expect(res.statusCode).toEqual(403)
           done()
 
     it 'User can fetch a prepaid code', (done) ->

From 0581ffde82cdf7280a36ad881f44a25e04120882 Mon Sep 17 00:00:00 2001
From: Scott Erickson <sderickson@gmail.com>
Date: Fri, 17 Jun 2016 10:35:22 -0700
Subject: [PATCH 04/28] Clean server test logging

---
 server/commons/logging.coffee                 |   9 +-
 .../earned_achievement_handler.coffee         |   4 +-
 server/handlers/payment_handler.coffee        |   2 +-
 server/handlers/subscription_handler.coffee   |   2 +-
 server/handlers/user_handler.coffee           |  12 +-
 server/middleware/courses.coffee              |   2 +-
 server/models/User.coffee                     |   1 -
 server_setup.coffee                           |   2 +-
 spec/helpers/helper.js                        |  12 +
 spec/server/common.coffee                     |  38 +-
 spec/server/functional/contact.spec.coffee    |  40 +-
 spec/server/functional/file.spec.coffee       | 356 +++++++++---------
 .../functional/level_session.spec.coffee      |  16 +-
 spec/server/functional/nocked.spec.coffee     |  29 --
 spec/server/functional/queue.spec.coffee      |  48 +--
 .../functional/subscription.spec.coffee       | 113 +++---
 spec/server/functional/user.spec.coffee       |  88 ++---
 spec/server/nock-utils.coffee                 |   2 +-
 spec/server/unit/analytics.spec.coffee        | 154 ++++----
 19 files changed, 445 insertions(+), 485 deletions(-)
 delete mode 100644 spec/server/functional/nocked.spec.coffee

diff --git a/server/commons/logging.coffee b/server/commons/logging.coffee
index 537e045e0..d0b7f6972 100644
--- a/server/commons/logging.coffee
+++ b/server/commons/logging.coffee
@@ -2,7 +2,8 @@ winston = require 'winston'
 
 module.exports.setup = ->
   winston.remove(winston.transports.Console)
-  winston.add(winston.transports.Console,
-    colorize: true,
-    timestamp: true
-  )
+  if not global.testing
+    winston.add(winston.transports.Console,
+      colorize: true,
+      timestamp: true
+    )
diff --git a/server/handlers/earned_achievement_handler.coffee b/server/handlers/earned_achievement_handler.coffee
index 53c3d8979..4a609d5a9 100644
--- a/server/handlers/earned_achievement_handler.coffee
+++ b/server/handlers/earned_achievement_handler.coffee
@@ -158,7 +158,7 @@ class EarnedAchievementHandler extends Handler
     onFinished = ->
       t1 = new Date().getTime()
       runningTime = ((t1-t0)/1000/60/60).toFixed(2)
-      console.log "we finished in #{runningTime} hours"
+      log.info "we finished in #{runningTime} hours"
       callback arguments...
 
     filter = {}
@@ -278,7 +278,7 @@ class EarnedAchievementHandler extends Handler
                 #log.debug "Incrementing score for these achievements with #{newTotalPoints - previousPoints}"
                 pointDelta = newTotalPoints - previousPoints
                 pctDone = (100 * usersFinished / total).toFixed(2)
-                console.log "Updated points to #{newTotalPoints} (#{if pointDelta < 0 then '' else '+'}#{pointDelta}) for #{user.get('name') or '???'} (#{user.get('_id')}) (#{pctDone}%)"
+                log.info "Updated points to #{newTotalPoints} (#{if pointDelta < 0 then '' else '+'}#{pointDelta}) for #{user.get('name') or '???'} (#{user.get('_id')}) (#{pctDone}%)"
                 if recalculatingAll
                   update = {$set: {points: newTotalPoints, 'earned.gems': 0, 'earned.heroes': [], 'earned.items': [], 'earned.levels': []}}
                 else
diff --git a/server/handlers/payment_handler.coffee b/server/handlers/payment_handler.coffee
index c43cb77d8..0487d0a8e 100644
--- a/server/handlers/payment_handler.coffee
+++ b/server/handlers/payment_handler.coffee
@@ -34,7 +34,7 @@ PaymentHandler = class PaymentHandler extends Handler
     super arguments...
 
   logPaymentError: (req, msg) ->
-    console.warn "Payment Error: #{req.user.get('slug')} (#{req.user._id}): '#{msg}'"
+    log.warn "Payment Error: #{req.user.get('slug')} (#{req.user._id}): '#{msg}'"
 
   makeNewInstance: (req) ->
     payment = super(req)
diff --git a/server/handlers/subscription_handler.coffee b/server/handlers/subscription_handler.coffee
index a01421404..cf1fe8134 100644
--- a/server/handlers/subscription_handler.coffee
+++ b/server/handlers/subscription_handler.coffee
@@ -21,7 +21,7 @@ recipientCouponID = 'free'
 
 class SubscriptionHandler extends Handler
   logSubscriptionError: (user, msg) ->
-    console.warn "Subscription Error: #{user.get('slug')} (#{user._id}): '#{msg}'"
+    log.warn "Subscription Error: #{user.get('slug')} (#{user._id}): '#{msg}'"
 
   getByRelationship: (req, res, args...) ->
     return @getStripeEvents(req, res) if args[1] is 'stripe_events'
diff --git a/server/handlers/user_handler.coffee b/server/handlers/user_handler.coffee
index 455aa90bb..73be9b4ad 100644
--- a/server/handlers/user_handler.coffee
+++ b/server/handlers/user_handler.coffee
@@ -119,7 +119,7 @@ UserHandler = class UserHandler extends Handler
         log.error "Database error setting user name: #{err}" if err
         return callback(res: 'Database error.', code: 500) if err
         r = {message: 'is already used by another account', property: 'name'}
-        console.log 'Another user exists' if otherUser
+        log.info 'Another user exists' if otherUser
         return callback({res: r, code: 409}) if otherUser
         user.set('name', req.body.name)
         callback(null, req, user)
@@ -775,7 +775,7 @@ UserHandler = class UserHandler extends Handler
         else
           update = $unset: {}
           update.$unset[statKey] = ''
-        console.log "... updating #{userStringID} patches #{statKey} to #{count}, #{usersTotal} players found so far." if count
+        log.info "... updating #{userStringID} patches #{statKey} to #{count}, #{usersTotal} players found so far." if count
         User.findByIdAndUpdate user.get('_id'), update, (err) ->
           log.error err if err?
           doneWithUser()
@@ -801,7 +801,7 @@ UserHandler = class UserHandler extends Handler
       update = {}
       update[method] = {}
       update[method][statName] = count or ''
-      console.log "... updating #{user.get('_id')} patches #{JSON.stringify(query)} #{statName} to #{count}, #{usersTotal} players found so far." if count
+      log.info "... updating #{user.get('_id')} patches #{JSON.stringify(query)} #{statName} to #{count}, #{usersTotal} players found so far." if count
       User.findByIdAndUpdate user.get('_id'), update, doneUpdatingUser
 
     userStream = User.find({anonymous: false}).sort('_id').stream()
@@ -865,7 +865,7 @@ UserHandler = class UserHandler extends Handler
         update = {}
         update[method] = {}
         update[method][statName] = count or ''
-        console.log "... updating #{userStringID} patches #{query} to #{count}, #{usersTotal} players found so far." if count
+        log.info "... updating #{userStringID} patches #{query} to #{count}, #{usersTotal} players found so far." if count
         User.findByIdAndUpdate user.get('_id'), update, doneWithUser
 
   statRecalculators:
@@ -883,7 +883,7 @@ UserHandler = class UserHandler extends Handler
         --numberRunning
         userStream.resume()
         if streamFinished and usersFinished is usersTotal
-          console.log "----------- Finished recalculating statistics for gamesCompleted for #{usersFinished} players. -----------"
+          log.info "----------- Finished recalculating statistics for gamesCompleted for #{usersFinished} players. -----------"
           done?()
       userStream.on 'error', (err) -> log.error err
       userStream.on 'close', -> streamFinished = true
@@ -895,7 +895,7 @@ UserHandler = class UserHandler extends Handler
 
         LevelSession.count {creator: userID, 'state.complete': true}, (err, count) ->
           update = if count then {$set: 'stats.gamesCompleted': count} else {$unset: 'stats.gamesCompleted': ''}
-          console.log "... updating #{userID} gamesCompleted to #{count}, #{usersTotal} players found so far." if Math.random() < 0.001
+          log.info "... updating #{userID} gamesCompleted to #{count}, #{usersTotal} players found so far." if Math.random() < 0.001
           User.findByIdAndUpdate user.get('_id'), update, doneWithUser
 
     articleEdits: (done) ->
diff --git a/server/middleware/courses.coffee b/server/middleware/courses.coffee
index 678b9f601..85b0a1240 100644
--- a/server/middleware/courses.coffee
+++ b/server/middleware/courses.coffee
@@ -39,7 +39,7 @@ module.exports =
       throw new errors.NotFound('Level original ObjectId not found in that course')
     
     if not nextLevelOriginal
-      res.status(200).send({})
+      return res.status(200).send({})
       
     dbq = Level.findOne({original: mongoose.Types.ObjectId(nextLevelOriginal)})
     
diff --git a/server/models/User.coffee b/server/models/User.coffee
index fa43f8c32..56cae0eb6 100644
--- a/server/models/User.coffee
+++ b/server/models/User.coffee
@@ -350,7 +350,6 @@ UserSchema.pre('save', (next) ->
   Classroom = require './Classroom'
   if @isTeacher() and not @wasTeacher
     Classroom.update({members: @_id}, {$pull: {members: @_id}}, {multi: true}).exec (err, res) ->
-      console.log 'removed self from all classrooms as a member', err, res
   if email = @get('email')
     @set('emailLower', email.toLowerCase())
   if name = @get('name')
diff --git a/server_setup.coffee b/server_setup.coffee
index c1adc9b79..4bf4fe632 100644
--- a/server_setup.coffee
+++ b/server_setup.coffee
@@ -84,7 +84,7 @@ setupExpressMiddleware = (app) ->
     app.use express.compress filter: (req, res) ->
       return false if req.headers.host is 'codecombat.com'  # CloudFlare will gzip it for us on codecombat.com
       compressible res.getHeader('Content-Type')
-  else
+  else if not global.testing
     express.logger.format('dev', developmentLogging)
     app.use(express.logger('dev'))
   app.use(express.static(path.join(__dirname, 'public'), maxAge: 0))  # CloudFlare overrides maxAge, and we don't want local development caching.
diff --git a/spec/helpers/helper.js b/spec/helpers/helper.js
index 09be41fa3..095663f95 100644
--- a/spec/helpers/helper.js
+++ b/spec/helpers/helper.js
@@ -39,11 +39,22 @@ if (database.generateMongoConnectionString() !== dbString) {
 jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 10; // for long Stripe tests
 require('../server/common'); // Make sure global testing functions are set up
 
+// Ignore Stripe/Nocking erroring
+console.error = function() {
+  try {
+    if(arguments[1].stack.indexOf('An error occurred with our connection to Stripe') > -1)
+      return;
+  }
+  catch (e) { }
+  console.log.apply(console, arguments);
+};
+
 var initialized = false;
 beforeEach(function(done) {
   if (initialized) {
     return done();
   }
+  console.log('/spec/helpers/helper.js - Initializing spec environment...');
 
   var async = require('async');
   async.series([
@@ -103,6 +114,7 @@ beforeEach(function(done) {
       process.exit(1);
     }
     initialized = true;
+    console.log('/spec/helpers/helper.js - Done');
     done();
   });
 });
diff --git a/spec/server/common.coffee b/spec/server/common.coffee
index ae6fa307c..29c47d0c3 100644
--- a/spec/server/common.coffee
+++ b/spec/server/common.coffee
@@ -1,7 +1,7 @@
 # import this at the top of every file so we're not juggling connections
 # and common libraries are available
 
-console.log 'IT BEGINS'
+console.log '/spec/server/common.coffee - Setting up spec globals...'
 if process.env.COCO_MONGO_HOST
   throw Error('Tests may not run with production environment')
 
@@ -60,7 +60,7 @@ unittest.getUser = (name, email, password, done, force) ->
   return done(unittest.users[email]) if unittest.users[email] and not force
   request.post getURL('/auth/logout'), ->
     request.get getURL('/auth/whoami'), ->
-      req = request.post(getURL('/db/user'), (err, response, body) ->
+      req = request.post({url: getURL('/db/user'), json: {email, password}}, (err, response, body) ->
         throw err if err
         User.findOne({email: email}).exec((err, user) ->
           throw err if err
@@ -70,9 +70,6 @@ unittest.getUser = (name, email, password, done, force) ->
             wrapUpGetUser(email, user, done)
         )
       )
-      form = req.form()
-      form.append('email', email)
-      form.append('password', password)
 
 wrapUpGetUser = (email, user, done) ->
   unittest.users[email] = user
@@ -139,58 +136,48 @@ GLOBAL.loginNewUser = (done) ->
   email = "#{name}@me.com"
   request.post getURL('/auth/logout'), ->
     unittest.getUser name, email, password, (user) ->
-      req = request.post(getURL('/auth/login'), (error, response) ->
+      json = {username: email, password}
+      req = request.post({url: getURL('/auth/login'), json}, (error, response) ->
         expect(response.statusCode).toBe(200)
         done(user)
       )
-      form = req.form()
-      form.append('username', email)
-      form.append('password', password)
     , true
 
 GLOBAL.loginJoe = (done) ->
   request.post getURL('/auth/logout'), ->
     unittest.getNormalJoe (user) ->
-      req = request.post(getURL('/auth/login'), (error, response) ->
+      json = {username: 'normal@jo.com', password: 'food'}
+      req = request.post({url: getURL('/auth/login'), json}, (error, response) ->
         expect(response.statusCode).toBe(200)
         done(user)
       )
-      form = req.form()
-      form.append('username', 'normal@jo.com')
-      form.append('password', 'food')
 
 GLOBAL.loginSam = (done) ->
   request.post getURL('/auth/logout'), ->
     unittest.getOtherSam (user) ->
-      req = request.post(getURL('/auth/login'), (error, response) ->
+      json = { username: 'other@sam.com', password: 'beer'}
+      req = request.post({url: getURL('/auth/login'), json}, (error, response) ->
         expect(response.statusCode).toBe(200)
         done(user)
       )
-      form = req.form()
-      form.append('username', 'other@sam.com')
-      form.append('password', 'beer')
 
 GLOBAL.loginAdmin = (done) ->
   request.post getURL('/auth/logout'), ->
     unittest.getAdmin (user) ->
-      req = request.post(getURL('/auth/login'), (error, response) ->
+      json = { username: 'admin@afc.com', password: '80yqxpb38j' }
+      req = request.post({url: getURL('/auth/login'), json}, (error, response) ->
         expect(response.statusCode).toBe(200)
         done(user)
       )
-      form = req.form()
-      form.append('username', 'admin@afc.com')
-      form.append('password', '80yqxpb38j')
       # find some other way to make the admin object an admin... maybe directly?
 
 GLOBAL.loginUser = (user, done) ->
   request.post getURL('/auth/logout'), ->
-    req = request.post(getURL('/auth/login'), (error, response) ->
+    json = { username: user.get('email'), password: user.get('name') }
+    req = request.post({ url: getURL('/auth/login'), json}, (error, response) ->
       expect(response.statusCode).toBe(200)
       done(user)
     )
-    form = req.form()
-    form.append('username', user.get('email'))
-    form.append('password', user.get('name'))
 
 GLOBAL.logoutUser = (done) ->
   request.post getURL('/auth/logout'), ->
@@ -213,3 +200,4 @@ _drop = (done) ->
 GLOBAL.resetUserIDCounter = (number=0) ->
   User.idCounter = number
 
+console.log '/spec/server/common.coffee - Done'
diff --git a/spec/server/functional/contact.spec.coffee b/spec/server/functional/contact.spec.coffee
index 0c18a201e..906b3baba 100644
--- a/spec/server/functional/contact.spec.coffee
+++ b/spec/server/functional/contact.spec.coffee
@@ -4,23 +4,23 @@ request = require '../request'
 User = require '../../../server/models/User'
 
 # TODO: need to update this test since /contact calls external Close.io API now
-xdescribe 'POST /contact', ->
-  
-  beforeEach utils.wrap (done) ->
-    spyOn(sendwithus.api, 'send')
-    @teacher = yield utils.initUser({role: 'teacher'})
-    yield utils.loginUser(@teacher)
-    done()
-  
-  describe 'when recipientID is "schools@codecombat.com"', ->
-    it 'sends to that email', utils.wrap (done) ->
-      [res, body] = yield request.postAsync({url: getURL('/contact'), json: {
-        sender: 'some@email.com'
-        message: 'A message'
-        recipientID: 'schools@codecombat.com'
-      }})
-      expect(sendwithus.api.send).toHaveBeenCalled()
-      user = yield User.findById(@teacher.id)
-      yield new Promise((resolve) -> setTimeout(resolve, 10))
-      expect(user.get('enrollmentRequestSent')).toBe(true)
-      done()
+#xdescribe 'POST /contact', ->
+#  
+#  beforeEach utils.wrap (done) ->
+#    spyOn(sendwithus.api, 'send')
+#    @teacher = yield utils.initUser({role: 'teacher'})
+#    yield utils.loginUser(@teacher)
+#    done()
+#  
+#  describe 'when recipientID is "schools@codecombat.com"', ->
+#    it 'sends to that email', utils.wrap (done) ->
+#      [res, body] = yield request.postAsync({url: getURL('/contact'), json: {
+#        sender: 'some@email.com'
+#        message: 'A message'
+#        recipientID: 'schools@codecombat.com'
+#      }})
+#      expect(sendwithus.api.send).toHaveBeenCalled()
+#      user = yield User.findById(@teacher.id)
+#      yield new Promise((resolve) -> setTimeout(resolve, 10))
+#      expect(user.get('enrollmentRequestSent')).toBe(true)
+#      done()
diff --git a/spec/server/functional/file.spec.coffee b/spec/server/functional/file.spec.coffee
index 26699bd0c..2282dbf4d 100644
--- a/spec/server/functional/file.spec.coffee
+++ b/spec/server/functional/file.spec.coffee
@@ -1,178 +1,178 @@
-require '../common'
-
-# Doesn't work on Travis. Need to figure out why, probably by having the
-# url not depend on some external resource.
-mongoose = require 'mongoose'
-request = require '../request'
-
-xdescribe '/file', ->
-  url = getURL('/file')
-  files = []
-  options = {
-    uri: url
-    json: {
-      # url: 'http://scotterickson.info/images/where-are-you.jpg'
-      url: 'http://fc07.deviantart.net/fs37/f/2008/283/5/1/Chu_Chu_Pikachu_by_angelishi.gif'
-      filename: 'where-are-you.jpg'
-      mimetype: 'image/jpeg'
-      description: 'None!'
-    }
-  }
-  filepath = 'tmp/file' # TODO Warning hard coded path !!!
-
-  jsonOptions= {
-    path: 'my_path'
-    postName: 'my_buffer'
-    filename: 'ittybitty.data'
-    mimetype: 'application/octet-stream'
-    description: 'rando-info'
-    # my_buffer_url: 'http://scotterickson.info/images/where-are-you.jpg'
-    my_buffer_url: 'http://fc07.deviantart.net/fs37/f/2008/283/5/1/Chu_Chu_Pikachu_by_angelishi.gif'
-  }
-
-  allowHeader = 'GET, POST'
-
-  it 'preparing test : deletes all the files first', (done) ->
-    dropGridFS ->
-      done()
-
-  it 'can\'t be created if invalid (property path is required)', (done) ->
-    func = (err, res, body) ->
-      expect(res.statusCode).toBe(422)
-      done()
-
-    loginAdmin  ->
-      request.post(options, func)
-
-  it 'can be created by an admin', (done) ->
-    func = (err, res, body) ->
-      expect(res.statusCode).toBe(200)
-      expect(body._id).toBeDefined()
-      expect(body.filename).toBe(options.json.filename)
-      expect(body.contentType).toBe(options.json.mimetype)
-      expect(body.length).toBeDefined()
-      expect(body.uploadDate).toBeDefined()
-      expect(body.metadata).toBeDefined()
-      expect(body.metadata.name).toBeDefined()
-      expect(body.metadata.path).toBe(options.json.path)
-      expect(body.metadata.creator).toBeDefined()
-      expect(body.metadata.description).toBe(options.json.description)
-      expect(body.md5).toBeDefined()
-      files.push(body)
-      done()
-
-    options.json.path = filepath
-    request.post(options, func)
-
-  it 'can be read by an admin.', (done) ->
-    request.get {uri: url+'/'+files[0]._id}, (err, res) ->
-      expect(res.statusCode).toBe(200)
-      expect(res.headers['content-type']).toBe(files[0].contentType)
-      done()
-
-  it 'returns 404 for missing files', (done) ->
-    id = '000000000000000000000000'
-    request.get {uri: url+'/'+id}, (err, res) ->
-      expect(res.statusCode).toBe(404)
-      done()
-
-  it 'returns 404 for invalid ids', (done) ->
-    request.get {uri: url+'/thiswillnotwork'}, (err, res) ->
-      expect(res.statusCode).toBe(404)
-      done()
-
-  it 'can be created directly with form parameters', (done) ->
-    options2 = {
-      uri: url
-    }
-
-    func = (err, res, body) ->
-      expect(res.statusCode).toBe(200)
-      body = JSON.parse(body)
-      expect(body._id).toBeDefined()
-      expect(body.filename).toBe(jsonOptions.filename)
-      expect(body.contentType).toBe(jsonOptions.mimetype)
-      expect(body.length).toBeDefined()
-      expect(body.uploadDate).toBeDefined()
-      expect(body.metadata).toBeDefined()
-      expect(body.metadata.name).toBeDefined()
-      expect(body.metadata.path).toBe(jsonOptions.path)
-      expect(body.metadata.creator).toBeDefined()
-      expect(body.metadata.description).toBe(jsonOptions.description)
-      expect(body.md5).toBeDefined()
-      files.push(body)
-      done()
-
-    # the only way I could figure out how to get request to do what I wanted...
-    r = request.post(options2, func)
-    form = r.form()
-    form.append('path', jsonOptions.path)
-    form.append('postName', jsonOptions.postName)
-    form.append('filename', jsonOptions.filename)
-    form.append('mimetype', jsonOptions.mimetype)
-    form.append('description', jsonOptions.description)
-    form.append('my_buffer', request(jsonOptions.my_buffer_url))
-
-  it 'created directly, can be read', (done) ->
-    request.get {uri: url+'/'+files[1]._id}, (err, res) ->
-      expect(res.statusCode).toBe(200)
-      expect(res.headers['content-type']).toBe(files[1].contentType)
-      done()
-
-  it 'does not overwrite existing files', (done) ->
-    options.json.description = 'Face'
-
-    func = (err, res, body) ->
-      expect(res.statusCode).toBe(409)
-      collection = mongoose.connection.db.collection('media.files')
-      collection.find({}).toArray (err, results) ->
-        # ittybitty.data, and just one Where are you.jpg
-        expect(results.length).toBe(2)
-        for f in results
-          expect(f.metadata.description).not.toBe('Face')
-        done()
-
-    request.post(options, func)
-
-  it 'does overwrite existing files if force is true', (done) ->
-    options.json.force = 'true' # TODO ask why it's a string and not a boolean ?
-
-    func = (err, res, body) ->
-      expect(res.statusCode).toBe(200)
-      collection = mongoose.connection.db.collection('media.files')
-      collection.find({}).toArray (err, results) ->
-        # ittybitty.data, and just one Where are you.jpg
-        expect(results.length).toBe(2)
-        hit = false
-        for f in results
-          hit = true if f.metadata.description is 'Face'
-        expect(hit).toBe(true)
-        done()
-
-    request.post(options, func)
-
-  it ' can\'t be requested with HTTP PATCH method', (done) ->
-    request {method: 'patch', uri: url}, (err, res) ->
-      expect(res.statusCode).toBe(405)
-      expect(res.headers.allow).toBe(allowHeader)
-      done()
-
-  it ' can\'t be requested with HTTP PUT method', (done) ->
-    request.put {uri: url}, (err, res) ->
-      expect(res.statusCode).toBe(405)
-      expect(res.headers.allow).toBe(allowHeader)
-      done()
-
-  it ' can\'t be requested with HTTP HEAD method', (done) ->
-    request.head {uri: url}, (err, res) ->
-      expect(res.statusCode).toBe(405)
-      expect(res.headers.allow).toBe(allowHeader)
-      done()
-
-  it ' can\'t be requested with HTTP DEL method', (done) ->
-    request.del {uri: url}, (err, res) ->
-      expect(res.statusCode).toBe(405)
-      expect(res.headers.allow).toBe(allowHeader)
-      done()
-
-# TODO: test server errors, see what they do
+#require '../common'
+#
+## Doesn't work on Travis. Need to figure out why, probably by having the
+## url not depend on some external resource.
+#mongoose = require 'mongoose'
+#request = require '../request'
+#
+#xdescribe '/file', ->
+#  url = getURL('/file')
+#  files = []
+#  options = {
+#    uri: url
+#    json: {
+#      # url: 'http://scotterickson.info/images/where-are-you.jpg'
+#      url: 'http://fc07.deviantart.net/fs37/f/2008/283/5/1/Chu_Chu_Pikachu_by_angelishi.gif'
+#      filename: 'where-are-you.jpg'
+#      mimetype: 'image/jpeg'
+#      description: 'None!'
+#    }
+#  }
+#  filepath = 'tmp/file' # TODO Warning hard coded path !!!
+#
+#  jsonOptions= {
+#    path: 'my_path'
+#    postName: 'my_buffer'
+#    filename: 'ittybitty.data'
+#    mimetype: 'application/octet-stream'
+#    description: 'rando-info'
+#    # my_buffer_url: 'http://scotterickson.info/images/where-are-you.jpg'
+#    my_buffer_url: 'http://fc07.deviantart.net/fs37/f/2008/283/5/1/Chu_Chu_Pikachu_by_angelishi.gif'
+#  }
+#
+#  allowHeader = 'GET, POST'
+#
+#  it 'preparing test : deletes all the files first', (done) ->
+#    dropGridFS ->
+#      done()
+#
+#  it 'can\'t be created if invalid (property path is required)', (done) ->
+#    func = (err, res, body) ->
+#      expect(res.statusCode).toBe(422)
+#      done()
+#
+#    loginAdmin  ->
+#      request.post(options, func)
+#
+#  it 'can be created by an admin', (done) ->
+#    func = (err, res, body) ->
+#      expect(res.statusCode).toBe(200)
+#      expect(body._id).toBeDefined()
+#      expect(body.filename).toBe(options.json.filename)
+#      expect(body.contentType).toBe(options.json.mimetype)
+#      expect(body.length).toBeDefined()
+#      expect(body.uploadDate).toBeDefined()
+#      expect(body.metadata).toBeDefined()
+#      expect(body.metadata.name).toBeDefined()
+#      expect(body.metadata.path).toBe(options.json.path)
+#      expect(body.metadata.creator).toBeDefined()
+#      expect(body.metadata.description).toBe(options.json.description)
+#      expect(body.md5).toBeDefined()
+#      files.push(body)
+#      done()
+#
+#    options.json.path = filepath
+#    request.post(options, func)
+#
+#  it 'can be read by an admin.', (done) ->
+#    request.get {uri: url+'/'+files[0]._id}, (err, res) ->
+#      expect(res.statusCode).toBe(200)
+#      expect(res.headers['content-type']).toBe(files[0].contentType)
+#      done()
+#
+#  it 'returns 404 for missing files', (done) ->
+#    id = '000000000000000000000000'
+#    request.get {uri: url+'/'+id}, (err, res) ->
+#      expect(res.statusCode).toBe(404)
+#      done()
+#
+#  it 'returns 404 for invalid ids', (done) ->
+#    request.get {uri: url+'/thiswillnotwork'}, (err, res) ->
+#      expect(res.statusCode).toBe(404)
+#      done()
+#
+#  it 'can be created directly with form parameters', (done) ->
+#    options2 = {
+#      uri: url
+#    }
+#
+#    func = (err, res, body) ->
+#      expect(res.statusCode).toBe(200)
+#      body = JSON.parse(body)
+#      expect(body._id).toBeDefined()
+#      expect(body.filename).toBe(jsonOptions.filename)
+#      expect(body.contentType).toBe(jsonOptions.mimetype)
+#      expect(body.length).toBeDefined()
+#      expect(body.uploadDate).toBeDefined()
+#      expect(body.metadata).toBeDefined()
+#      expect(body.metadata.name).toBeDefined()
+#      expect(body.metadata.path).toBe(jsonOptions.path)
+#      expect(body.metadata.creator).toBeDefined()
+#      expect(body.metadata.description).toBe(jsonOptions.description)
+#      expect(body.md5).toBeDefined()
+#      files.push(body)
+#      done()
+#
+#    # the only way I could figure out how to get request to do what I wanted...
+#    r = request.post(options2, func)
+#    form = r.form()
+#    form.append('path', jsonOptions.path)
+#    form.append('postName', jsonOptions.postName)
+#    form.append('filename', jsonOptions.filename)
+#    form.append('mimetype', jsonOptions.mimetype)
+#    form.append('description', jsonOptions.description)
+#    form.append('my_buffer', request(jsonOptions.my_buffer_url))
+#
+#  it 'created directly, can be read', (done) ->
+#    request.get {uri: url+'/'+files[1]._id}, (err, res) ->
+#      expect(res.statusCode).toBe(200)
+#      expect(res.headers['content-type']).toBe(files[1].contentType)
+#      done()
+#
+#  it 'does not overwrite existing files', (done) ->
+#    options.json.description = 'Face'
+#
+#    func = (err, res, body) ->
+#      expect(res.statusCode).toBe(409)
+#      collection = mongoose.connection.db.collection('media.files')
+#      collection.find({}).toArray (err, results) ->
+#        # ittybitty.data, and just one Where are you.jpg
+#        expect(results.length).toBe(2)
+#        for f in results
+#          expect(f.metadata.description).not.toBe('Face')
+#        done()
+#
+#    request.post(options, func)
+#
+#  it 'does overwrite existing files if force is true', (done) ->
+#    options.json.force = 'true' # TODO ask why it's a string and not a boolean ?
+#
+#    func = (err, res, body) ->
+#      expect(res.statusCode).toBe(200)
+#      collection = mongoose.connection.db.collection('media.files')
+#      collection.find({}).toArray (err, results) ->
+#        # ittybitty.data, and just one Where are you.jpg
+#        expect(results.length).toBe(2)
+#        hit = false
+#        for f in results
+#          hit = true if f.metadata.description is 'Face'
+#        expect(hit).toBe(true)
+#        done()
+#
+#    request.post(options, func)
+#
+#  it ' can\'t be requested with HTTP PATCH method', (done) ->
+#    request {method: 'patch', uri: url}, (err, res) ->
+#      expect(res.statusCode).toBe(405)
+#      expect(res.headers.allow).toBe(allowHeader)
+#      done()
+#
+#  it ' can\'t be requested with HTTP PUT method', (done) ->
+#    request.put {uri: url}, (err, res) ->
+#      expect(res.statusCode).toBe(405)
+#      expect(res.headers.allow).toBe(allowHeader)
+#      done()
+#
+#  it ' can\'t be requested with HTTP HEAD method', (done) ->
+#    request.head {uri: url}, (err, res) ->
+#      expect(res.statusCode).toBe(405)
+#      expect(res.headers.allow).toBe(allowHeader)
+#      done()
+#
+#  it ' can\'t be requested with HTTP DEL method', (done) ->
+#    request.del {uri: url}, (err, res) ->
+#      expect(res.statusCode).toBe(405)
+#      expect(res.headers.allow).toBe(allowHeader)
+#      done()
+#
+## TODO: test server errors, see what they do
diff --git a/spec/server/functional/level_session.spec.coffee b/spec/server/functional/level_session.spec.coffee
index 02b48b220..5b445b029 100644
--- a/spec/server/functional/level_session.spec.coffee
+++ b/spec/server/functional/level_session.spec.coffee
@@ -23,14 +23,14 @@ describe '/db/level.session', ->
 
   # TODO Tried to mimic what happens on the site. Why is this even so hard to do.
   # Right now it's even possible to create ownerless sessions through POST
-  xit 'allows users to create level sessions through PATCH', (done) ->
-    loginJoe (joe) ->
-      request {method: 'patch', uri: url + mongoose.Types.ObjectId(), json: session}, (err, res, body) ->
-        expect(err).toBeNull()
-        expect(res.statusCode).toBe 200
-        console.log body
-        expect(body.creator).toEqual joe.get('_id').toHexString()
-        done()
+#  xit 'allows users to create level sessions through PATCH', (done) ->
+#    loginJoe (joe) ->
+#      request {method: 'patch', uri: url + mongoose.Types.ObjectId(), json: session}, (err, res, body) ->
+#        expect(err).toBeNull()
+#        expect(res.statusCode).toBe 200
+#        console.log body
+#        expect(body.creator).toEqual joe.get('_id').toHexString()
+#        done()
 
   # Should remove this as soon as the PATCH test case above works
   it 'create a level session', (done) ->
diff --git a/spec/server/functional/nocked.spec.coffee b/spec/server/functional/nocked.spec.coffee
deleted file mode 100644
index 3c02c0b9f..000000000
--- a/spec/server/functional/nocked.spec.coffee
+++ /dev/null
@@ -1,29 +0,0 @@
-require '../common'
-config = require '../../../server_config'
-nockUtils = require('../nock-utils')
-request = require '../request'
-
-xdescribe 'nock-utils', ->
-  afterEach nockUtils.teardownNock
-
-  describe 'a test using setupNock', ->
-    it 'records and plays back third-party requests, but not localhost requests', (done) ->
-      nockUtils.setupNock 'nock-test.json', (err, nockDone) ->
-        request.get { uri: getURL('/db/level') }, (err) ->
-          expect(err).toBeNull()
-          t0 = new Date().getTime()
-          request.get { uri: 'http://zombo.com/' }, (err) ->
-            console.log 'cached speed', new Date().getTime() - t0
-            expect(err).toBeNull()
-            nockDone()
-            done()
-    
-  describe 'another, sibling test that does not use setupNock', ->
-    it 'is proceeds normally', (done) ->
-      request.get { uri: getURL('/db/level') }, (err) ->
-        expect(err).toBeNull()
-        t0 = new Date().getTime()
-        request.get { uri: 'http://zombo.com/' }, (err) ->
-          console.log 'uncached speed', new Date().getTime() - t0
-          expect(err).toBeNull()
-          done()
diff --git a/spec/server/functional/queue.spec.coffee b/spec/server/functional/queue.spec.coffee
index 4f622d310..91cd90f35 100644
--- a/spec/server/functional/queue.spec.coffee
+++ b/spec/server/functional/queue.spec.coffee
@@ -1,24 +1,24 @@
-require '../common'
-request = require '../request'
-
-describe 'queue', ->
-  someURL = getURL('/queue/')
-  allowHeader = 'GET, POST, PUT'
-
-  xit 'can\'t be requested with HTTP PATCH method', (done) ->
-    request {method: 'patch', uri: someURL}, (err, res, body) ->
-      expect(res.statusCode).toBe(405)
-      expect(res.headers.allow).toBe(allowHeader)
-      done()
-
-  xit 'can\'t be requested with HTTP HEAD method', (done) ->
-    request.head {uri: someURL}, (err, res, body) ->
-      expect(res.statusCode).toBe(405)
-      expect(res.headers.allow).toBe(allowHeader)
-      done()
-
-  xit 'can\'t be requested with HTTP DELETE method', (done) ->
-    request.del {uri: someURL}, (err, res, body) ->
-      expect(res.statusCode).toBe(405)
-      expect(res.headers.allow).toBe(allowHeader)
-      done()
+#require '../common'
+#request = require '../request'
+#
+#describe 'queue', ->
+#  someURL = getURL('/queue/')
+#  allowHeader = 'GET, POST, PUT'
+#
+#  xit 'can\'t be requested with HTTP PATCH method', (done) ->
+#    request {method: 'patch', uri: someURL}, (err, res, body) ->
+#      expect(res.statusCode).toBe(405)
+#      expect(res.headers.allow).toBe(allowHeader)
+#      done()
+#
+#  xit 'can\'t be requested with HTTP HEAD method', (done) ->
+#    request.head {uri: someURL}, (err, res, body) ->
+#      expect(res.statusCode).toBe(405)
+#      expect(res.headers.allow).toBe(allowHeader)
+#      done()
+#
+#  xit 'can\'t be requested with HTTP DELETE method', (done) ->
+#    request.del {uri: someURL}, (err, res, body) ->
+#      expect(res.statusCode).toBe(405)
+#      expect(res.headers.allow).toBe(allowHeader)
+#      done()
diff --git a/spec/server/functional/subscription.spec.coffee b/spec/server/functional/subscription.spec.coffee
index 08425668e..57c2b2c26 100644
--- a/spec/server/functional/subscription.spec.coffee
+++ b/spec/server/functional/subscription.spec.coffee
@@ -1441,62 +1441,62 @@ describe 'Subscriptions', ->
                                                               nockDone()
                                                               done()
 
-      xit 'Unsubscribed user1 subscribes 13 users, unsubcribes 2', (done) ->
-        nockUtils.setupNock 'sub-test-34.json', (err, nockDone) ->
-          # TODO: Hits the Stripe error 'Request rate limit exceeded'.
-          # TODO: Need a better test for 12+ bulk discounts. Or, we could update the bulk disount logic.
-          # TODO: verify interim invoices?
-          recipientCount = 13
-          recipientsToVerify = [0, 1, 10, 11, 12]
-          recipients = new SubbedRecipients recipientCount, recipientsToVerify
-
-          # Create recipients
-          recipients.createRecipients ->
-            expect(recipients.length()).toEqual(recipientCount)
-
-            stripe.tokens.create {
-              card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
-            }, (err, token) ->
-
-              # Create sponsor user
-              loginNewUser (user1) ->
-
-                # Subscribe recipients
-                recipients.subRecipients user1, token, ->
-                  User.findById user1.id, (err, user1) ->
-
-                    # Unsubscribe first recipient
-                    unsubscribeRecipient user1, recipients.get(0), ->
-                      User.findById user1.id, (err, user1) ->
-
-                        stripeInfo = user1.get('stripe')
-                        expect(stripeInfo.recipients.length).toEqual(recipientCount - 1)
-                        verifyNotSponsoring user1.id, recipients.get(0).id, ->
-                          verifyNotRecipient recipients.get(0).id, ->
-                            stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
-                              expect(err).toBeNull()
-                              expect(subscription).not.toBeNull()
-                              expect(subscription.quantity).toEqual(getUnsubscribedQuantity(recipientCount - 1))
-
-                              # Unsubscribe last recipient
-                              unsubscribeRecipient user1, recipients.get(recipientCount - 1), ->
-                                User.findById user1.id, (err, user1) ->
-                                  stripeInfo = user1.get('stripe')
-                                  expect(stripeInfo.recipients.length).toEqual(recipientCount - 2)
-                                  verifyNotSponsoring user1.id, recipients.get(recipientCount - 1).id, ->
-                                    verifyNotRecipient recipients.get(recipientCount - 1).id, ->
-                                      stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
-                                        expect(err).toBeNull()
-                                        expect(subscription).not.toBeNull()
-                                        numSponsored = recipientCount - 2
-                                        if numSponsored <= 1
-                                          expect(subscription.quantity).toEqual(subPrice)
-                                        else if numSponsored <= 11
-                                          expect(subscription.quantity).toEqual(subPrice + (numSponsored - 1) * subPrice * 0.8)
-                                        else
-                                          expect(subscription.quantity).toEqual(subPrice + 10 * subPrice * 0.8 + (numSponsored - 11) * subPrice * 0.6)
-                                        nockDone()
-                                        done()
+#      xit 'Unsubscribed user1 subscribes 13 users, unsubcribes 2', (done) ->
+#        nockUtils.setupNock 'sub-test-34.json', (err, nockDone) ->
+#          # TODO: Hits the Stripe error 'Request rate limit exceeded'.
+#          # TODO: Need a better test for 12+ bulk discounts. Or, we could update the bulk disount logic.
+#          # TODO: verify interim invoices?
+#          recipientCount = 13
+#          recipientsToVerify = [0, 1, 10, 11, 12]
+#          recipients = new SubbedRecipients recipientCount, recipientsToVerify
+#
+#          # Create recipients
+#          recipients.createRecipients ->
+#            expect(recipients.length()).toEqual(recipientCount)
+#
+#            stripe.tokens.create {
+#              card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
+#            }, (err, token) ->
+#
+#              # Create sponsor user
+#              loginNewUser (user1) ->
+#
+#                # Subscribe recipients
+#                recipients.subRecipients user1, token, ->
+#                  User.findById user1.id, (err, user1) ->
+#
+#                    # Unsubscribe first recipient
+#                    unsubscribeRecipient user1, recipients.get(0), ->
+#                      User.findById user1.id, (err, user1) ->
+#
+#                        stripeInfo = user1.get('stripe')
+#                        expect(stripeInfo.recipients.length).toEqual(recipientCount - 1)
+#                        verifyNotSponsoring user1.id, recipients.get(0).id, ->
+#                          verifyNotRecipient recipients.get(0).id, ->
+#                            stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
+#                              expect(err).toBeNull()
+#                              expect(subscription).not.toBeNull()
+#                              expect(subscription.quantity).toEqual(getUnsubscribedQuantity(recipientCount - 1))
+#
+#                              # Unsubscribe last recipient
+#                              unsubscribeRecipient user1, recipients.get(recipientCount - 1), ->
+#                                User.findById user1.id, (err, user1) ->
+#                                  stripeInfo = user1.get('stripe')
+#                                  expect(stripeInfo.recipients.length).toEqual(recipientCount - 2)
+#                                  verifyNotSponsoring user1.id, recipients.get(recipientCount - 1).id, ->
+#                                    verifyNotRecipient recipients.get(recipientCount - 1).id, ->
+#                                      stripe.customers.retrieveSubscription stripeInfo.customerID, stripeInfo.sponsorSubscriptionID, (err, subscription) ->
+#                                        expect(err).toBeNull()
+#                                        expect(subscription).not.toBeNull()
+#                                        numSponsored = recipientCount - 2
+#                                        if numSponsored <= 1
+#                                          expect(subscription.quantity).toEqual(subPrice)
+#                                        else if numSponsored <= 11
+#                                          expect(subscription.quantity).toEqual(subPrice + (numSponsored - 1) * subPrice * 0.8)
+#                                        else
+#                                          expect(subscription.quantity).toEqual(subPrice + 10 * subPrice * 0.8 + (numSponsored - 11) * subPrice * 0.6)
+#                                        nockDone()
+#                                        done()
 
   describe 'APIs', ->
     subscriptionURL = getURL('/db/subscription')
@@ -1694,7 +1694,6 @@ describe 'Subscriptions', ->
                   token: token.id
                   timestamp: new Date()
               request.put {uri: "#{subscriptionURL}/-/year_sale", json: requestBody, headers: headers }, (err, res) ->
-                console.log err
                 expect(err).toBeNull()
                 nockDone()
                 done()
diff --git a/spec/server/functional/user.spec.coffee b/spec/server/functional/user.spec.coffee
index 69e22e1c8..a735088d6 100644
--- a/spec/server/functional/user.spec.coffee
+++ b/spec/server/functional/user.spec.coffee
@@ -11,16 +11,13 @@ describe 'POST /db/user', ->
   createAnonNameUser = (name, done)->
     request.post getURL('/auth/logout'), ->
       request.get getURL('/auth/whoami'), ->
-        req = request.post(getURL('/db/user'), (err, response) ->
+        req = request.post({ url: getURL('/db/user'), json: {name}}, (err, response) ->
           expect(response.statusCode).toBe(200)
-          request.get getURL('/auth/whoami'), (request, response, body) ->
-            res = JSON.parse(response.body)
-            expect(res.anonymous).toBeTruthy()
-            expect(res.name).toEqual(name)
+          request.get { url: getURL('/auth/whoami'), json: true }, (request, response, body) ->
+            expect(body.anonymous).toBeTruthy()
+            expect(body.name).toEqual(name)
             done()
         )
-        form = req.form()
-        form.append('name', name)
 
   it 'preparing test : clears the db first', (done) ->
     clearModels [User], (err) ->
@@ -77,16 +74,13 @@ describe 'POST /db/user', ->
     createAnonNameUser('Jim', done)
 
   it 'should allow setting existing user name to anonymous user', (done) ->
-    req = request.post(getURL('/db/user'), (err, response, body) ->
+    req = request.post({url: getURL('/db/user'), json: {email: 'new@user.com', password: 'new'}}, (err, response, body) ->
       expect(response.statusCode).toBe(200)
       request.get getURL('/auth/whoami'), (request, response, body) ->
         res = JSON.parse(response.body)
         expect(res.anonymous).toBeFalsy()
         createAnonNameUser 'Jim', done
     )
-    form = req.form()
-    form.append('email', 'new@user.com')
-    form.append('password', 'new')
 
 describe 'PUT /db/user', ->
 
@@ -103,23 +97,22 @@ describe 'PUT /db/user', ->
 
   it 'denies requests to edit someone who is not joe', (done) ->
     unittest.getAdmin (admin) ->
-      req = request.put getURL(urlUser),
-      (err, res) ->
+      request.put {url: getURL(urlUser), json: {_id: admin.id}}, (err, res) ->
         expect(res.statusCode).toBe(403)
         done()
-      req.form().append('_id', admin.id)
 
   it 'denies invalid data', (done) ->
     unittest.getNormalJoe (joe) ->
-      req = request.put getURL(urlUser),
-      (err, res) ->
+      json = {
+        _id: joe.id
+        email: 'farghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlar
+ghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghl'
+      }
+      request.put { url: getURL(urlUser), json }, (err, res) ->
         expect(res.statusCode).toBe(422)
-        expect(res.body.indexOf('too long')).toBeGreaterThan(-1)
+        expect(res.body[0].message.indexOf('too long')).toBeGreaterThan(-1)
         done()
-      form = req.form()
-      form.append('_id', joe.id)
-      form.append('email', 'farghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlar
-ghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghl')
+      
 
   it 'does not allow normals to edit their permissions', utils.wrap (done) ->
     user = yield utils.initUser()
@@ -132,47 +125,45 @@ ghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghl
     loginAdmin -> done()
 
   it 'denies non-existent ids', (done) ->
-    req = request.put getURL(urlUser),
-    (err, res) ->
+    json = {
+      _id: '513108d4cb8b610000000004',
+      email: 'perfectly@good.com'
+    }
+    request.put {url: getURL(urlUser), json}, (err, res) ->
       expect(res.statusCode).toBe(404)
       done()
-    form = req.form()
-    form.append('_id', '513108d4cb8b610000000004')
-    form.append('email', 'perfectly@good.com')
 
   it 'denies if the email being changed is already taken', (done) ->
     unittest.getNormalJoe (joe) ->
       unittest.getAdmin (admin) ->
-        req = request.put getURL(urlUser), (err, res) ->
+        json = { _id: admin.id, email: joe.get('email').toUpperCase() }
+        request.put { url: getURL(urlUser), json }, (err, res) ->
           expect(res.statusCode).toBe(409)
-          expect(res.body.indexOf('already used')).toBeGreaterThan(-1)
+          expect(res.body.message.indexOf('already used')).toBeGreaterThan(-1)
           done()
-        form = req.form()
-        form.append('_id', String(admin._id))
-        form.append('email', joe.get('email').toUpperCase())
 
   it 'does not care if you include your existing name', (done) ->
     unittest.getNormalJoe (joe) ->
-      req = request.put getURL(urlUser+'/'+joe._id), (err, res) ->
+      json = { _id: joe._id, name: 'Joe' }
+      request.put { url: getURL(urlUser+'/'+joe._id), json }, (err, res) ->
         expect(res.statusCode).toBe(200)
         done()
-      form = req.form()
-      form.append('_id', String(joe._id))
-      form.append('name', 'Joe')
 
   it 'accepts name and email changes', (done) ->
     unittest.getNormalJoe (joe) ->
-      req = request.put getURL(urlUser), (err, res) ->
+      json = {
+        _id: joe.id
+        email: 'New@email.com'
+        name: 'Wilhelm'
+      }
+      request.put { url: getURL(urlUser), json }, (err, res) ->
         expect(res.statusCode).toBe(200)
         unittest.getUser('Wilhelm', 'New@email.com', 'null', (joe) ->
           expect(joe.get('name')).toBe('Wilhelm')
           expect(joe.get('emailLower')).toBe('new@email.com')
           expect(joe.get('email')).toBe('New@email.com')
           done())
-      form = req.form()
-      form.append('_id', String(joe._id))
-      form.append('email', 'New@email.com')
-      form.append('name', 'Wilhelm')
+      
 
   it 'should not allow two users with the same name slug', (done) ->
     loginSam (sam) ->
@@ -189,7 +180,8 @@ ghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghl
   it 'should silently rename an anonymous user if their name conflicts upon signup', (done) ->
     request.post getURL('/auth/logout'), ->
       request.get getURL('/auth/whoami'), ->
-        req = request.post getURL('/db/user'), (err, response) ->
+        json = { name: 'admin' }
+        request.post { url: getURL('/db/user'), json }, (err, response) ->
           expect(response.statusCode).toBe(200)
           request.get getURL('/auth/whoami'), (err, response) ->
             expect(err).toBeNull()
@@ -205,8 +197,6 @@ ghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghlfarghlarghl
               expect(finalGuy.name).not.toEqual guy.name
               expect(finalGuy.name.length).toBe guy.name.length + 1
               done()
-        form = req.form()
-        form.append('name', 'admin')
 
   it 'should be able to unset a slug by setting an empty name', (done) ->
     loginSam (sam) ->
@@ -467,13 +457,13 @@ describe 'PUT /db/user/-/remain-teacher', ->
 describe 'GET /db/user', ->
 
   it 'logs in as admin', (done) ->
-    req = request.post(getURL('/auth/login'), (error, response) ->
+    json = {
+      username: 'admin@afc.com'
+      password: '80yqxpb38j'
+    }
+    request.post { url: getURL('/auth/login'), json }, (error, response) ->
       expect(response.statusCode).toBe(200)
       done()
-    )
-    form = req.form()
-    form.append('username', 'admin@afc.com')
-    form.append('password', '80yqxpb38j')
 
   it 'get schema', (done) ->
     request.get {uri: getURL(urlUser+'/schema')}, (err, res, body) ->
@@ -523,7 +513,7 @@ describe 'GET /db/user', ->
   # TODO Ruben should be able to fetch other users but probably with restricted data access
   # Add to the test case above an extra data check
 
-  xit 'can fetch another user with restricted fields'
+#  xit 'can fetch another user with restricted fields'
   
   
 describe 'GET /db/user/:handle', ->
diff --git a/spec/server/nock-utils.coffee b/spec/server/nock-utils.coffee
index 308e373da..d18d63d1c 100644
--- a/spec/server/nock-utils.coffee
+++ b/spec/server/nock-utils.coffee
@@ -77,4 +77,4 @@ module.exports.teardownNock = ->
 before = (scope) ->
   scope.body = (body) -> true
   
-Promise.promisifyAll(module.exports)
\ No newline at end of file
+Promise.promisifyAll(module.exports)
diff --git a/spec/server/unit/analytics.spec.coffee b/spec/server/unit/analytics.spec.coffee
index c6dbc2605..22f1bc66a 100644
--- a/spec/server/unit/analytics.spec.coffee
+++ b/spec/server/unit/analytics.spec.coffee
@@ -1,77 +1,77 @@
-GLOBAL._ = require 'lodash'
-
-require '../common'
-AnalyticsUsersActive = require '../../../server/models/AnalyticsUsersActive'
-LevelSession = require '../../../server/models/LevelSession'
-User = require '../../../server/models/User'
-mongoose = require 'mongoose'
-
-# TODO: these tests have some rerun/cleanup issues
-# TODO: add tests for purchase, payment, subscribe, unsubscribe, and earned achievements
-
-# TODO: AnalyticsUsersActive collection isn't currently used.
-# TODO: Will remove these tests if we end up ripping out the disabled saveActiveUser calls.
-
-describe 'Analytics', ->
-
-  xit 'registered user', (done) ->
-    clearModels [AnalyticsUsersActive], (err) ->
-      expect(err).toBeNull()
-      user = new User
-        permissions: []
-        name: "Fred" + Math.floor(Math.random() * 10000)
-      user.save (err) ->
-        expect(err).toBeNull()
-        userID = mongoose.Types.ObjectId(user.get('_id'))
-        AnalyticsUsersActive.find {creator : userID}, (err, activeUsers) ->
-          expect(activeUsers.length).toEqual(0)
-          user.register ->
-            AnalyticsUsersActive.find {creator : userID}, (err, activeUsers) ->
-              expect(err).toBeNull()
-              expect(activeUsers.length).toEqual(1)
-              expect(activeUsers[0]?.get('event')).toEqual('register')
-              done()
-
-  xit 'level completed', (done) ->
-    clearModels [AnalyticsUsersActive], (err) ->
-      expect(err).toBeNull()
-      unittest.getNormalJoe (joe) ->
-        userID = mongoose.Types.ObjectId(joe.get('_id'))
-        session = new LevelSession
-          name: 'Beat Gandalf'
-          levelID: 'lotr'
-          permissions: simplePermissions
-          state: complete: false
-          creator: userID
-        session.save (err) ->
-          expect(err).toBeNull()
-          AnalyticsUsersActive.find {creator : userID}, (err, activeUsers) ->
-            expect(activeUsers.length).toEqual(0)
-            session.set 'state', complete: true
-            session.save (err) ->
-              expect(err).toBeNull()
-              AnalyticsUsersActive.find {creator : userID}, (err, activeUsers) ->
-                expect(err).toBeNull()
-                expect(activeUsers.length).toEqual(1)
-                expect(activeUsers[0]?.get('event')).toEqual('level-completed/lotr')
-                done()
-
-  xit 'level playtime', (done) ->
-    clearModels [AnalyticsUsersActive], (err) ->
-      expect(err).toBeNull()
-      unittest.getNormalJoe (joe) ->
-        userID = mongoose.Types.ObjectId(joe.get('_id'))
-        session = new LevelSession
-          name: 'Beat Gandalf'
-          levelID: 'lotr'
-          permissions: simplePermissions
-          playtime: 60
-          creator: userID
-        session.save (err) ->
-          expect(err).toBeNull()
-          AnalyticsUsersActive.find {creator : userID}, (err, activeUsers) ->
-            expect(err).toBeNull()
-            expect(activeUsers.length).toEqual(1)
-            expect(activeUsers[0]?.get('event')).toEqual('level-playtime/lotr')
-            done()
-
+#GLOBAL._ = require 'lodash'
+#
+#require '../common'
+#AnalyticsUsersActive = require '../../../server/models/AnalyticsUsersActive'
+#LevelSession = require '../../../server/models/LevelSession'
+#User = require '../../../server/models/User'
+#mongoose = require 'mongoose'
+#
+## TODO: these tests have some rerun/cleanup issues
+## TODO: add tests for purchase, payment, subscribe, unsubscribe, and earned achievements
+#
+## TODO: AnalyticsUsersActive collection isn't currently used.
+## TODO: Will remove these tests if we end up ripping out the disabled saveActiveUser calls.
+#
+#describe 'Analytics', ->
+#
+#  xit 'registered user', (done) ->
+#    clearModels [AnalyticsUsersActive], (err) ->
+#      expect(err).toBeNull()
+#      user = new User
+#        permissions: []
+#        name: "Fred" + Math.floor(Math.random() * 10000)
+#      user.save (err) ->
+#        expect(err).toBeNull()
+#        userID = mongoose.Types.ObjectId(user.get('_id'))
+#        AnalyticsUsersActive.find {creator : userID}, (err, activeUsers) ->
+#          expect(activeUsers.length).toEqual(0)
+#          user.register ->
+#            AnalyticsUsersActive.find {creator : userID}, (err, activeUsers) ->
+#              expect(err).toBeNull()
+#              expect(activeUsers.length).toEqual(1)
+#              expect(activeUsers[0]?.get('event')).toEqual('register')
+#              done()
+#
+#  xit 'level completed', (done) ->
+#    clearModels [AnalyticsUsersActive], (err) ->
+#      expect(err).toBeNull()
+#      unittest.getNormalJoe (joe) ->
+#        userID = mongoose.Types.ObjectId(joe.get('_id'))
+#        session = new LevelSession
+#          name: 'Beat Gandalf'
+#          levelID: 'lotr'
+#          permissions: simplePermissions
+#          state: complete: false
+#          creator: userID
+#        session.save (err) ->
+#          expect(err).toBeNull()
+#          AnalyticsUsersActive.find {creator : userID}, (err, activeUsers) ->
+#            expect(activeUsers.length).toEqual(0)
+#            session.set 'state', complete: true
+#            session.save (err) ->
+#              expect(err).toBeNull()
+#              AnalyticsUsersActive.find {creator : userID}, (err, activeUsers) ->
+#                expect(err).toBeNull()
+#                expect(activeUsers.length).toEqual(1)
+#                expect(activeUsers[0]?.get('event')).toEqual('level-completed/lotr')
+#                done()
+#
+#  xit 'level playtime', (done) ->
+#    clearModels [AnalyticsUsersActive], (err) ->
+#      expect(err).toBeNull()
+#      unittest.getNormalJoe (joe) ->
+#        userID = mongoose.Types.ObjectId(joe.get('_id'))
+#        session = new LevelSession
+#          name: 'Beat Gandalf'
+#          levelID: 'lotr'
+#          permissions: simplePermissions
+#          playtime: 60
+#          creator: userID
+#        session.save (err) ->
+#          expect(err).toBeNull()
+#          AnalyticsUsersActive.find {creator : userID}, (err, activeUsers) ->
+#            expect(err).toBeNull()
+#            expect(activeUsers.length).toEqual(1)
+#            expect(activeUsers[0]?.get('event')).toEqual('level-playtime/lotr')
+#            done()
+#

From 8657f978672d5201396e0eadb14d24aaa9c6e715 Mon Sep 17 00:00:00 2001
From: Scott Erickson <sderickson@gmail.com>
Date: Fri, 17 Jun 2016 10:42:59 -0700
Subject: [PATCH 05/28] Switch travis client test progress to dots

---
 .travis.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.travis.yml b/.travis.yml
index f3c0f484b..de84f4bd4 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -28,7 +28,7 @@ before_script:
   - "sleep 15" # to give node a chance to start
 
 script:
-  - "./node_modules/karma/bin/karma start --browsers Firefox --single-run --reporters progress"
+  - "./node_modules/karma/bin/karma start --browsers Firefox --single-run --reporters dots"
   - "npm run jasmine"
 
 notifications:

From b4baad82b0a89f56c4a47eddcb25131980ba2ce4 Mon Sep 17 00:00:00 2001
From: Rob <rob@codecombat.com>
Date: Fri, 17 Jun 2016 11:41:43 -0700
Subject: [PATCH 06/28] Don't set up the analytics log model in proxy mode.

---
 server/models/AnalyticsLogEvent.coffee | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/server/models/AnalyticsLogEvent.coffee b/server/models/AnalyticsLogEvent.coffee
index db974f4a9..77241b0b5 100644
--- a/server/models/AnalyticsLogEvent.coffee
+++ b/server/models/AnalyticsLogEvent.coffee
@@ -28,6 +28,9 @@ AnalyticsLogEventSchema.statics.logEvent = (user, event, properties={}) ->
 
   doc.save()
 
-analyticsMongoose = mongoose.createConnection "mongodb://#{config.mongo.analytics_host}:#{config.mongo.analytics_port}/#{config.mongo.analytics_db}"
-
-module.exports = AnalyticsLogEvent = analyticsMongoose.model('analytics.log.event', AnalyticsLogEventSchema, config.mongo.analytics_collection)
+unless config.proxy
+  analyticsMongoose = mongoose.createConnection()
+  analyticsMongoose.open "mongodb://#{config.mongo.analytics_host}:#{config.mongo.analytics_port}/#{config.mongo.analytics_db}", (error) ->
+    console.log "Couldnt connect to analytics", error
+  
+  module.exports = AnalyticsLogEvent = analyticsMongoose.model('analytics.log.event', AnalyticsLogEventSchema, config.mongo.analytics_collection)

From 490ea8d1bc4f8020a125ca808e9cbec6273c8451 Mon Sep 17 00:00:00 2001
From: Scott Erickson <sderickson@gmail.com>
Date: Fri, 17 Jun 2016 13:53:49 -0700
Subject: [PATCH 07/28] Add state to delighted data if state was populated by
 nces

---
 server/delighted.coffee | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/server/delighted.coffee b/server/delighted.coffee
index 1e603b451..636074a7a 100644
--- a/server/delighted.coffee
+++ b/server/delighted.coffee
@@ -17,6 +17,8 @@ module.exports.addDelightedUser = addDelightedUser = (user, trialRequest) ->
       testGroupNumber: user.get('testGroupNumber')
       gender: user.get('gender')
       lastLevel: user.get('lastLevel')
+      state: if props.nces_id and props.country is 'USA' then props.state else 'other'
+      
   @postPeople(form)
   
 module.exports.postPeople = (form) ->

From 514fce349a8f58388e1275cc6406c42fdf5ad78a Mon Sep 17 00:00:00 2001
From: Scott Erickson <sderickson@gmail.com>
Date: Fri, 17 Jun 2016 15:15:13 -0700
Subject: [PATCH 08/28] Include ladder levels in level stats in teacher views,
 clean client test logs

---
 app/lib/LevelLoader.coffee                    | 14 +++---
 app/lib/coursesHelper.coffee                  | 49 +++++++++----------
 app/models/CocoModel.coffee                   | 10 ++--
 app/models/LevelSession.coffee                |  2 +-
 app/templates/account/payments-view.jade      |  1 -
 app/templates/courses/teacher-class-view.jade |  4 +-
 server/middleware/classrooms.coffee           |  6 +--
 test/app/lib/CoursesHelper.spec.coffee        |  1 -
 8 files changed, 43 insertions(+), 44 deletions(-)

diff --git a/app/lib/LevelLoader.coffee b/app/lib/LevelLoader.coffee
index b4b03df8c..cbf7bc450 100644
--- a/app/lib/LevelLoader.coffee
+++ b/app/lib/LevelLoader.coffee
@@ -12,6 +12,8 @@ app = require 'core/application'
 World = require 'lib/world/world'
 utils = require 'core/utils'
 
+LOG = false
+
 # This is an initial stab at unifying loading and setup into a single place which can
 # monitor everything and keep a LoadingScreen visible overall progress.
 #
@@ -147,7 +149,7 @@ module.exports = class LevelLoader extends CocoClass
         @listenToOnce @opponentSession, 'sync', @loadDependenciesForSession
 
   loadDependenciesForSession: (session) ->
-    console.log "Loading dependencies for session: ", session
+    console.log "Loading dependencies for session: ", session if LOG
     if me.id isnt session.get 'creator'
       session.patch = session.save = -> console.error "Not saving session, since we didn't create it."
     else if codeLanguage = utils.getQueryVariable 'codeLanguage'
@@ -172,11 +174,11 @@ module.exports = class LevelLoader extends CocoClass
       @consolidateFlagHistory() if @session.loaded
     if @level.get('type', true) in ['course'] # course-ladder is hard to handle because there's 2 sessions
       heroConfig = me.get('heroConfig')
-      console.log "Course mode, loading custom hero: ", heroConfig
+      console.log "Course mode, loading custom hero: ", heroConfig if LOG
       return if not heroConfig
       url = "/db/thang.type/#{heroConfig.thangType}/version"
       if heroResource = @maybeLoadURL(url, ThangType, 'thang')
-        console.log "Pushing resource: ", heroResource
+        console.log "Pushing resource: ", heroResource if LOG
         @worldNecessities.push heroResource
       @sessionDependenciesRegistered[session.id] = true
       return
@@ -345,7 +347,7 @@ module.exports = class LevelLoader extends CocoClass
     true
 
   onWorldNecessitiesLoaded: ->
-    console.log "World necessities loaded."
+    console.log "World necessities loaded." if LOG
     @initWorld()
     @supermodel.clearMaxProgress()
     @trigger 'world-necessities-loaded'
@@ -374,7 +376,7 @@ module.exports = class LevelLoader extends CocoClass
 
   onSupermodelLoaded: ->
     return if @destroyed
-    console.log 'SuperModel for Level loaded in', new Date().getTime() - @t0, 'ms'
+    console.log 'SuperModel for Level loaded in', new Date().getTime() - @t0, 'ms' if LOG
     @loadLevelSounds()
     @denormalizeSession()
 
@@ -482,7 +484,7 @@ module.exports = class LevelLoader extends CocoClass
       @world.difficulty = Math.max 0, @world.difficulty - 1  # Show the difficulty they won, not the next one.
     serializedLevel = @level.serialize(@supermodel, @session, @opponentSession)
     @world.loadFromLevel serializedLevel, false
-    console.log 'World has been initialized from level loader.'
+    console.log 'World has been initialized from level loader.' if LOG
 
   # Initial Sound Loading
 
diff --git a/app/lib/coursesHelper.coffee b/app/lib/coursesHelper.coffee
index 693234fa2..ad68f1fee 100644
--- a/app/lib/coursesHelper.coffee
+++ b/app/lib/coursesHelper.coffee
@@ -12,20 +12,18 @@ module.exports =
         continue if not instance
         instance.numCompleted = 0
         instance.started = false
-        levels = classroom.getLevels({courseID: course.id, withoutLadderLevels: true})
+        levels = classroom.getLevels({courseID: course.id})
         for userID in instance.get('members')
           instance.started ||= _.any levels.models, (level) ->
-            return false if level.isLadder()
             session = _.find classroom.sessions.models, (session) ->
               session.get('creator') is userID and session.get('level').original is level.get('original')
             session?
           levelCompletes = _.map levels.models, (level) ->
-            return true if level.isLadder()
             #TODO: Hella slow! Do the mapping first!
-            session = _.find classroom.sessions.models, (session) ->
+            sessions = _.filter classroom.sessions.models, (session) ->
               session.get('creator') is userID and session.get('level').original is level.get('original')
             # sessionMap[userID][level].completed()
-            session?.completed()
+            _.find(sessions, (s) -> s.completed())
           if _.every levelCompletes
             instance.numCompleted += 1
 
@@ -34,14 +32,14 @@ module.exports =
     for course, courseIndex in courses.models
       instance = courseInstances.findWhere({ courseID: course.id, classroomID: classroom.id })
       continue if not instance
-      levels = classroom.getLevels({courseID: course.id, withoutLadderLevels: true})
+      levels = classroom.getLevels({courseID: course.id})
       for level, levelIndex in levels.models
         userIDs = []
         for user in students.models
           userID = user.id
-          session = _.find classroom.sessions.models, (session) ->
+          sessions = _.filter classroom.sessions.models, (session) ->
             session.get('creator') is userID and session.get('level').original is level.get('original')
-          if not session?.completed()
+          if not _.find(sessions, (s) -> s.completed())
             userIDs.push userID
         if userIDs.length > 0
           users = _.map userIDs, (id) ->
@@ -61,16 +59,16 @@ module.exports =
       courseIndex = courses.models.length - courseIndex - 1 #compensate for reverse
       instance = courseInstances.findWhere({ courseID: course.id, classroomID: classroom.id })
       continue if not instance
-      levels = classroom.getLevels({courseID: course.id, withoutLadderLevels: true})
+      levels = classroom.getLevels({courseID: course.id})
       levelModels = levels.models.slice()
       for level, levelIndex in levelModels.reverse() #
         levelIndex = levelModels.length - levelIndex - 1 #compensate for reverse
         userIDs = []
         for user in students.models
           userID = user.id
-          session = _.find classroom.sessions.models, (session) ->
+          sessions = _.filter classroom.sessions.models, (session) ->
             session.get('creator') is userID and session.get('level').original is level.get('original')
-          if session?.completed() #
+          if _.find(sessions, (s) -> s.completed()) #
             userIDs.push userID
         if userIDs.length > 0
           users = _.map userIDs, (id) ->
@@ -91,7 +89,7 @@ module.exports =
       conceptData[classroom.id] = {}
       
       for course, courseIndex in courses.models
-        levels = classroom.getLevels({courseID: course.id, withoutLadderLevels: true})
+        levels = classroom.getLevels({courseID: course.id})
         
         for level in levels.models
           levelID = level.get('original')
@@ -102,16 +100,16 @@ module.exports =
 
           for concept in level.get('concepts')
             for userID in classroom.get('members')
-              session = _.find classroom.sessions.models, (session) ->
+              sessions = _.filter classroom.sessions.models, (session) ->
                 session.get('creator') is userID and session.get('level').original is levelID
               
-              if not session # haven't gotten to this level yet, but might have completed others before
+              if _.size(sessions) is 0 # haven't gotten to this level yet, but might have completed others before
                 for concept in level.get('concepts')
                   conceptData[classroom.id][concept].completed = false
-              if session # have gotten to the level and at least started it
+              if _.size(sessions) > 0 # have gotten to the level and at least started it
                 for concept in level.get('concepts')
                   conceptData[classroom.id][concept].started = true
-              if not session?.completed() # level started but not completed
+              if not _.find(sessions, (s) -> s.completed()) # level started but not completed
                 for concept in level.get('concepts')
                   conceptData[classroom.id][concept].completed = false
     conceptData
@@ -139,7 +137,7 @@ module.exports =
           continue
         progressData[classroom.id][course.id] = { completed: true, started: false } # to be updated
 
-        levels = classroom.getLevels({courseID: course.id, withoutLadderLevels: true})
+        levels = classroom.getLevels({courseID: course.id})
         for level in levels.models
           levelID = level.get('original')
           progressData[classroom.id][course.id][levelID] = {
@@ -154,12 +152,12 @@ module.exports =
             courseProgress = progressData[classroom.id][course.id]
             courseProgress[userID] ?= { completed: true, started: false, levelsCompleted: 0 } # Only set it the first time through a user
             courseProgress[levelID][userID] = { completed: true, started: false } # These don't matter, will always be set
-            session = _.find classroom.sessions.models, (session) ->
+            sessions = _.filter classroom.sessions.models, (session) ->
               session.get('creator') is userID and session.get('level').original is levelID
-
-            courseProgress[levelID][userID].session = session
             
-            if not session # haven't gotten to this level yet, but might have completed others before
+            courseProgress[levelID][userID].session = _.find(sessions, (s) -> s.completed()) or _.first(sessions)
+
+            if _.size(sessions) is 0 # haven't gotten to this level yet, but might have completed others before
               courseProgress.started ||= false #no-op
               courseProgress.completed = false
               courseProgress[userID].started ||= false #no-op
@@ -169,22 +167,23 @@ module.exports =
               courseProgress[levelID][userID].started = false
               courseProgress[levelID][userID].completed = false
               
-            if session # have gotten to the level and at least started it
+            if _.size(sessions) > 0 # have gotten to the level and at least started it
               courseProgress.started = true
               courseProgress[userID].started = true
               courseProgress[levelID].started = true
               courseProgress[levelID][userID].started = true
-              courseProgress[levelID][userID].lastPlayed = new Date(session.get('changed'))
+              courseProgress[levelID][userID].lastPlayed = new Date(Math.max(_.map(sessions, 'changed')))
               courseProgress[levelID].numStarted += 1
             
-            if session?.completed() # have finished this level
+            if _.find(sessions, (s) -> s.completed()) # have finished this level
               courseProgress.completed &&= true #no-op
               courseProgress[userID].completed &&= true #no-op
               courseProgress[userID].levelsCompleted += 1
               courseProgress[levelID].completed &&= true #no-op
               # courseProgress[levelID].numCompleted += 1
               courseProgress[levelID][userID].completed = true
-              courseProgress[levelID][userID].dateFirstCompleted = new Date(session.get('dateFirstCompleted') || session.get('changed'))
+              dates = (s.get('dateFirstCompleted') || s.get('changed') for s in sessions)
+              courseProgress[levelID][userID].dateFirstCompleted = new Date(Math.max(dates...))
             else # level started but not completed
               courseProgress.completed = false
               courseProgress[userID].completed = false
diff --git a/app/models/CocoModel.coffee b/app/models/CocoModel.coffee
index 4a5c84fb5..90310d5de 100644
--- a/app/models/CocoModel.coffee
+++ b/app/models/CocoModel.coffee
@@ -124,10 +124,11 @@ class CocoModel extends Backbone.Model
   validate: ->
     errors = @getValidationErrors()
     if errors?.length
-      console.debug "Validation failed for #{@constructor.className}: '#{@get('name') or @}'."
-      for error in errors
-        console.debug "\t", error.dataPath, ':', error.message
-      console.trace?()
+      unless application.testing
+        console.debug "Validation failed for #{@constructor.className}: '#{@get('name') or @}'."
+        for error in errors
+          console.debug "\t", error.dataPath, ':', error.message
+        console.trace?()
       return errors
 
   save: (attrs, options) ->
@@ -188,7 +189,6 @@ class CocoModel extends Backbone.Model
         keys.push key
 
     return unless keys.length
-    console.debug 'Patching', @get('name') or @, keys
     @save(attrs, options)
 
   fetch: (options) ->
diff --git a/app/models/LevelSession.coffee b/app/models/LevelSession.coffee
index 63621fa66..8b2b73104 100644
--- a/app/models/LevelSession.coffee
+++ b/app/models/LevelSession.coffee
@@ -41,7 +41,7 @@ module.exports = class LevelSession extends CocoModel
     @get('submittedCodeLanguage')? and @get('team')?
 
   completed: ->
-    @get('state')?.complete || false
+    @get('state')?.complete || @get('submitted') || false
 
   shouldAvoidCorruptData: (attrs) ->
     return false unless me.team is 'humans'
diff --git a/app/templates/account/payments-view.jade b/app/templates/account/payments-view.jade
index fc625f656..f9a55bd8b 100644
--- a/app/templates/account/payments-view.jade
+++ b/app/templates/account/payments-view.jade
@@ -10,7 +10,6 @@ block content
       a(href="/account", data-i18n="nav.account")
     li.active(data-i18n="account.payments")
 
-  - console.log('render', view.payments.size())
   if view.payments.size()
     table.table.table-striped
       tr
diff --git a/app/templates/courses/teacher-class-view.jade b/app/templates/courses/teacher-class-view.jade
index 7215a08cf..453f8485c 100644
--- a/app/templates/courses/teacher-class-view.jade
+++ b/app/templates/courses/teacher-class-view.jade
@@ -300,7 +300,7 @@ mixin courseProgressTab
 
 mixin courseOverview
   - var course = state.get('selectedCourse')
-  - var levels = view.classroom.getLevels({courseID: course.id, withoutLadderLevels: true}).models
+  - var levels = view.classroom.getLevels({courseID: course.id}).models
   .course-overview-row
     .course-title.student-name
       span= course.get('name')
@@ -318,7 +318,7 @@ mixin studentLevelsRow(student)
       div.student-email.small-details= student.get('email')
     div.student-levels-progress
       - var course = state.get('selectedCourse')
-      - var levels = view.classroom.getLevels({courseID: course.id, withoutLadderLevels: true}).models
+      - var levels = view.classroom.getLevels({courseID: course.id}).models
       each level, index in levels
         - var progress = state.get('progressData').get({ classroom: view.classroom, course: course, level: level, user: student })
         +studentLevelProgressDot(progress, level, index+1, session)
diff --git a/server/middleware/classrooms.coffee b/server/middleware/classrooms.coffee
index 1c2ec0f78..8a12b3c5a 100644
--- a/server/middleware/classrooms.coffee
+++ b/server/middleware/classrooms.coffee
@@ -22,7 +22,7 @@ module.exports =
   fetchByCode: wrap (req, res, next) ->
     code = req.query.code
     return next() unless code
-    classroom = yield Classroom.findOne({ code: code.toLowerCase().replace(/ /g, '') }).select('name ownerID aceConfig')
+    classroom = yield Classroom.findOne({ code: code.toLowerCase().replace(RegExp(' ', 'g') , '') }).select('name ownerID aceConfig')
     if not classroom
       log.debug("classrooms.fetchByCode: Couldn't find Classroom with code: #{code}")
       throw new errors.NotFound('Classroom not found.')
@@ -104,7 +104,7 @@ module.exports =
     members = classroom.get('members') or []
     members = members.slice(memberSkip, memberSkip + memberLimit)
     dbqs = []
-    select = 'state.complete level creator playtime changed dateFirstCompleted'
+    select = 'state.complete level creator playtime changed dateFirstCompleted submitted'
     for member in members
       dbqs.push(LevelSession.find({creator: member.toHexString()}).select(select).exec())
     results = yield dbqs
@@ -170,7 +170,7 @@ module.exports =
     if req.user.isTeacher()
       log.debug("classrooms.join: Cannot join a classroom as a teacher: #{req.user.id}")
       throw new errors.Forbidden('Cannot join a classroom as a teacher')
-    code = req.body.code.toLowerCase().replace(/ /g, '')
+    code = req.body.code.toLowerCase().replace(RegExp(' ', 'g'), '')
     classroom = yield Classroom.findOne({code: code})
     if not classroom
       log.debug("classrooms.join: Classroom not found with code #{code}")
diff --git a/test/app/lib/CoursesHelper.spec.coffee b/test/app/lib/CoursesHelper.spec.coffee
index 243f01920..2512c8eb5 100644
--- a/test/app/lib/CoursesHelper.spec.coffee
+++ b/test/app/lib/CoursesHelper.spec.coffee
@@ -36,7 +36,6 @@ describe 'CoursesHelper', ->
       describe 'progressData.get({classroom, course})', ->
         it 'returns object with .completed=true and .started=true', ->
           progressData = helper.calculateAllProgress(@classrooms, @courses, @courseInstances, @members)
-          console.log 'progress data?', progressData
           progress = progressData.get {@classroom, @course}
           expect(progress.completed).toBe true
           expect(progress.started).toBe true

From 4622337d82efdcb02c38ecfeb4b5d24dc770b01b Mon Sep 17 00:00:00 2001
From: Matt Lott <mattlott@live.com>
Date: Fri, 17 Jun 2016 15:40:03 -0700
Subject: [PATCH 09/28] Update licenses needed form

Ensure the needed licenses are in the subject resulting email.
---
 app/locale/en.coffee                          |  1 +
 app/templates/courses/enrollments-view.jade   |  3 --
 .../teachers/teachers-contact-modal.jade      | 24 ++++++++---
 app/views/courses/EnrollmentsView.coffee      | 14 +-----
 .../teachers/TeachersContactModal.coffee      | 43 +++++++++++--------
 server/routes/contact.coffee                  |  4 +-
 .../views/courses/EnrollmentsView.spec.coffee | 10 -----
 .../courses/TeachersContactModal.spec.coffee  | 12 +++++-
 8 files changed, 59 insertions(+), 52 deletions(-)

diff --git a/app/locale/en.coffee b/app/locale/en.coffee
index 29528035a..3bf4586f5 100644
--- a/app/locale/en.coffee
+++ b/app/locale/en.coffee
@@ -815,6 +815,7 @@
     more_info_1: "Our"
     more_info_2: "teachers forum"
     more_info_3: "is a good place to connect with fellow educators who are using CodeCombat."
+    licenses_needed: "Licenses needed"
 
   teachers_quote:
     name: "Demo Form"
diff --git a/app/templates/courses/enrollments-view.jade b/app/templates/courses/enrollments-view.jade
index babd88a79..0ec9f35fb 100644
--- a/app/templates/courses/enrollments-view.jade
+++ b/app/templates/courses/enrollments-view.jade
@@ -101,9 +101,6 @@ mixin addCredits
       button#request-sent-btn.btn-lg.btn.btn-forest(disabled=true, data-i18n="teacher.request_sent")
     else
       p(data-i18n="teacher.num_enrollments_needed")
-      div.m-t-2
-        input#students-input.enrollment-count.text-center(value=view.state.get('numberOfStudents') type='number')
-      strong(data-i18n="teacher.credits")
       p.m-y-2(data-i18n="teacher.get_enrollments_blurb")
       button#contact-us-btn.btn-lg.btn.btn-forest(data-i18n="contribute.contact_us_url")
 
diff --git a/app/templates/teachers/teachers-contact-modal.jade b/app/templates/teachers/teachers-contact-modal.jade
index de3826063..a795d9792 100644
--- a/app/templates/teachers/teachers-contact-modal.jade
+++ b/app/templates/teachers/teachers-contact-modal.jade
@@ -1,5 +1,7 @@
 extends /templates/core/modal-base-flat
 
+//- TODO: i18n
+
 block modal-header-content
   .text-center
     h3 Contact Our Classroom Team
@@ -11,26 +13,36 @@ block modal-body-content
     - var sent = view.state.get('sendingState') === 'sent';
     - var values = view.state.get('formValues');
     - var errors = view.state.get('formErrors');
-    
+
+    .form-group(class=errors.name ? 'has-error' : '')
+      label.control-label(for="name" data-i18n="general.name")
+      +formErrors(errors.name)
+      input.form-control(name="name", type="text", value=values.name || '', tabindex=1, disabled=sending || sent)
+
     .form-group(class=errors.email ? 'has-error' : '')
       label.control-label(for="email" data-i18n="general.email")
       +formErrors(errors.email)
       input.form-control(name="email", type="email", value=values.email || '', tabindex=1, disabled=sending || sent)
-        
+
+    .form-group(class=errors.licensesNeeded ? 'has-error' : '')
+      label.control-label(for="licensesNeeded" data-i18n="teachers.licenses_needed")
+      +formErrors(errors.licensesNeeded)
+      input.form-control(name="licensesNeeded", type="text", value=values.licensesNeeded || '', tabindex=1, disabled=sending || sent)
+
     .form-group(class=errors.message ? 'has-error' : '')
       label.control-label(for="message" data-i18n="general.message")
       +formErrors(errors.message)
       textarea.form-control(name="message", tabindex=1 disabled=sending || sent)= values.message
-      
+
     if view.state.get('sendingState') === 'error'
       .alert.alert-danger Could not send message.
-      
+
     if sent
       .alert.alert-success Message sent!
-      
+
     .text-right
       button#submit-btn.btn.btn-navy.btn-lg(type='submit' disabled=sending || sent) Submit
-      
+
 block modal-footer
 
 mixin formErrors(errors)
diff --git a/app/views/courses/EnrollmentsView.coffee b/app/views/courses/EnrollmentsView.coffee
index bcc3b3596..4e91ae1a3 100644
--- a/app/views/courses/EnrollmentsView.coffee
+++ b/app/views/courses/EnrollmentsView.coffee
@@ -14,7 +14,6 @@ module.exports = class EnrollmentsView extends RootView
   template: template
 
   events:
-    'input #students-input': 'onInputStudentsInput'
     'click #enroll-students-btn': 'onClickEnrollStudentsButton'
     'click #how-to-enroll-link': 'onClickHowToEnrollLink'
     'click #contact-us-btn': 'onClickContactUsButton'
@@ -96,17 +95,8 @@ module.exports = class EnrollmentsView extends RootView
     @openModalView(new HowToEnrollModal())
 
   onClickContactUsButton: ->
-    window.tracker?.trackEvent 'Classes Licenses Contact Us', category: 'Teachers', enrollmentsNeeded: @state.get('numberOfStudents'), ['Mixpanel']
-    @openModalView(new TeachersContactModal({ enrollmentsNeeded: @state.get('numberOfStudents') }))
-
-  onInputStudentsInput: ->
-    input = @$('#students-input').val()
-    if input isnt "" and (parseFloat(input) isnt parseInt(input) or _.isNaN parseInt(input))
-      @$('#students-input').val(@state.get('numberOfStudents'))
-    else
-      @state.set({'numberOfStudents': Math.max(parseInt(@$('#students-input').val()) or 0, 0)}, {silent: true}) # do not re-render
-
-  numberOfStudentsIsValid: -> 0 < @get('numberOfStudents') < 100000
+    window.tracker?.trackEvent 'Classes Licenses Contact Us', category: 'Teachers', ['Mixpanel']
+    @openModalView(new TeachersContactModal())
 
   onClickEnrollStudentsButton: ->
     window.tracker?.trackEvent 'Classes Licenses Enroll Students', category: 'Teachers', ['Mixpanel']
diff --git a/app/views/teachers/TeachersContactModal.coffee b/app/views/teachers/TeachersContactModal.coffee
index ec19bd824..ab8fbf06b 100644
--- a/app/views/teachers/TeachersContactModal.coffee
+++ b/app/views/teachers/TeachersContactModal.coffee
@@ -7,20 +7,22 @@ contact = require 'core/contact'
 module.exports = class TeachersContactModal extends ModalView
   id: 'teachers-contact-modal'
   template: require 'templates/teachers/teachers-contact-modal'
-  
+  defaultLicenses: 15
+
   events:
     'submit form': 'onSubmitForm'
-  
+
   initialize: (options={}) ->
     @state = new State({
       formValues: {
+        name: ''
         email: ''
+        licensesNeeded: @defaultLicenses
         message: ''
       }
       formErrors: {}
       sendingState: 'standby' # 'sending', 'sent', 'error'
     })
-    @enrollmentsNeeded = options.enrollmentsNeeded or '-'
     @trialRequests = new TrialRequests()
     @supermodel.trackRequest @trialRequests.fetchOwn()
     @state.on 'change', @render, @
@@ -28,41 +30,46 @@ module.exports = class TeachersContactModal extends ModalView
   onLoaded: ->
     trialRequest = @trialRequests.first()
     props = trialRequest?.get('properties') or {}
-    message = """
-        Name of School/District: #{props.organization or ''}
-        Your Name: #{props.name || ''}
-        Enrollments Needed: #{@enrollmentsNeeded}
-        
-        Message: Hi CodeCombat! I want to learn more about the Classroom experience and get licenses so that my students can access Computer Science 2 and on. 
-      """
+    name = if props.firstName and props.lastName then "#{props.firstName} #{props.lastName}" else me.get('name') ? ''
     email = props.email or me.get('email') or ''
-    @state.set('formValues', { email, message })
+    message = """
+        Hi CodeCombat! I want to learn more about the Classroom experience and get licenses so that my students can access Computer Science 2 and on.
+
+        Name of School/District: #{props.organization or ''}
+        Role: #{props.role or ''}
+        Phone Number: #{props.phoneNumber or ''}
+      """
+    @state.set('formValues', { name, email, licensesNeeded: @defaultLicenses, message })
     super()
 
   onSubmitForm: (e) ->
     e.preventDefault()
     return if @state.get('sendingState') is 'sending'
-    
+
     formValues = forms.formToObject @$el
     @state.set('formValues', formValues)
-    
+
     formErrors = {}
-    if not forms.validateEmail(formValues.email)
+    unless formValues.name
+      formErrors.name = 'Name required.'
+    unless forms.validateEmail(formValues.email)
       formErrors.email = 'Invalid email.'
-    if not formValues.message
+    unless parseInt(formValues.licensesNeeded) > 0
+      formErrors.licensesNeeded = 'Licenses needed is required.'
+    unless formValues.message
       formErrors.message = 'Message required.'
     @state.set({ formErrors, formValues, sendingState: 'standby' })
     return unless _.isEmpty(formErrors)
-    
+
     @state.set('sendingState', 'sending')
-    data = _.extend({ country: me.get('country'), recipientID: 'schools@codecombat.com', enrollmentsNeeded: @enrollmentsNeeded }, formValues)
+    data = _.extend({ country: me.get('country'), recipientID: 'schools@codecombat.com' }, formValues)
     contact.send({
       data
       context: @
       success: ->
         @state.set({ sendingState: 'sent' })
         me.set('enrollmentRequestSent', true)
-        setTimeout(=> 
+        setTimeout(=>
           @hide?()
         , 3000)
       error: -> @state.set({ sendingState: 'error' })
diff --git a/server/routes/contact.coffee b/server/routes/contact.coffee
index 651639bcb..f391a9050 100644
--- a/server/routes/contact.coffee
+++ b/server/routes/contact.coffee
@@ -25,11 +25,11 @@ module.exports.setup = (app) ->
 
 createMailContent = (req, fromAddress, done) ->
   country = req.body.country
-  enrollmentsNeeded = req.body.enrollmentsNeeded
+  licensesNeeded = req.body.licensesNeeded
   message = req.body.message
   user = req.user
   subject = switch
-    when enrollmentsNeeded then "#{enrollmentsNeeded} Licenses needed for #{fromAddress}"
+    when licensesNeeded then "#{licensesNeeded} Licenses needed for #{fromAddress}"
     when req.body.subject then req.body.subject
     else "Contact Us Form: #{fromAddress}"
   level = if user?.get('points') > 0 then Math.floor(5 * Math.log((1 / 100) * (user.get('points') + 100))) + 1 else 0
diff --git a/test/app/views/courses/EnrollmentsView.spec.coffee b/test/app/views/courses/EnrollmentsView.spec.coffee
index 75ba21deb..52c91a6e1 100644
--- a/test/app/views/courses/EnrollmentsView.spec.coffee
+++ b/test/app/views/courses/EnrollmentsView.spec.coffee
@@ -66,16 +66,6 @@ describe 'EnrollmentsView', ->
       fail('There should be an #action-col, other tests depend on it.')
 
   describe '"Get Licenses" area', ->
-    
-    describe '"Contact Us" button', ->
-      it 'opens a TeachersContactModal, passing in the number of licenses', ->
-        spyOn(@view, 'openModalView')
-        @view.state.set('numberOfStudents', 20)
-        @view.$('#contact-us-btn').click()
-        expect(view.openModalView).toHaveBeenCalled()
-        args = view.openModalView.calls.argsFor(0)
-        expect(args[0] instanceof TeachersContactModal).toBe(true)
-        expect(args[0].enrollmentsNeeded).toBe(20)
 
     describe 'when the teacher has made contact', ->
       beforeEach ->
diff --git a/test/app/views/courses/TeachersContactModal.spec.coffee b/test/app/views/courses/TeachersContactModal.spec.coffee
index 69552323e..0750b74f9 100644
--- a/test/app/views/courses/TeachersContactModal.spec.coffee
+++ b/test/app/views/courses/TeachersContactModal.spec.coffee
@@ -4,18 +4,28 @@ factories = require 'test/app/factories'
 
 describe 'TeachersContactModal', ->
   beforeEach (done) ->
-    @modal = new TeachersContactModal({ enrollmentsNeeded: 10 })
+    @modal = new TeachersContactModal()
     @modal.render()
     trialRequests = new TrialRequests([factories.makeTrialRequest()])
     @modal.trialRequests.fakeRequests[0].respondWith({ status: 200, responseText: trialRequests.stringify() })
     @modal.supermodel.once('loaded-all', done)
     jasmine.demoModal(@modal)
     
+  it 'shows an error when the name is empty and the form is submitted', ->
+    @modal.$('input[name="name"]').val('')
+    @modal.$('form').submit()
+    expect(@modal.$('input[name="name"]').closest('.form-group').hasClass('has-error')).toBe(true)
+
   it 'shows an error when the email is invalid and the form is submitted', ->
     @modal.$('input[name="email"]').val('not an email')
     @modal.$('form').submit()
     expect(@modal.$('input[name="email"]').closest('.form-group').hasClass('has-error')).toBe(true)
 
+  it 'shows an error when licensesNeeded is not > 0 and the form is submitted', ->
+    @modal.$('input[name="licensesNeeded"]').val('')
+    @modal.$('form').submit()
+    expect(@modal.$('input[name="licensesNeeded"]').closest('.form-group').hasClass('has-error')).toBe(true)
+
   it 'shows an error when the message is empty and the form is submitted', ->
     @modal.$('textarea[name="message"]').val('')
     @modal.$('form').submit()

From 190c5407c8d3ad6d29c2ae229809c09cf7d0f678 Mon Sep 17 00:00:00 2001
From: Matt Lott <mattlott@live.com>
Date: Fri, 17 Jun 2016 21:05:46 -0700
Subject: [PATCH 10/28] ZenProspect to Close contact import script

---
 scripts/addZenProspectLeadsToClose.js | 190 ++++++++++++++++++++++++++
 1 file changed, 190 insertions(+)
 create mode 100644 scripts/addZenProspectLeadsToClose.js

diff --git a/scripts/addZenProspectLeadsToClose.js b/scripts/addZenProspectLeadsToClose.js
new file mode 100644
index 000000000..15d2989ef
--- /dev/null
+++ b/scripts/addZenProspectLeadsToClose.js
@@ -0,0 +1,190 @@
+// Copy ZenProspect contacts with email replies into Close.io leads
+
+'use strict';
+if (process.argv.length !== 4) {
+  console.log("Usage: node <script> <Close.io general API key> <ZenProspect auth token>");
+  process.exit();
+}
+
+const closeIoApiKey = process.argv[2];
+const zpAuthToken = process.argv[3];
+
+const scriptStartTime = new Date();
+
+const async = require('async');
+const request = require('request');
+
+const zpPageSize = 100;
+
+getZPRepliedContacts((err, emailContactMap) => {
+  if (err) {
+    console.log(err);
+    return;
+  }
+  const tasks = [];
+  for (const email in emailContactMap) {
+    const contact = emailContactMap[email];
+    // if (contact.organization !== 'Cabarrus County Schools') continue;
+    tasks.push(createUpsertCloseLeadFn(contact));
+  }
+  async.parallel(tasks, (err, results) => {
+    if (err) console.log(err);
+    console.log("Script runtime: " + (new Date() - scriptStartTime));
+  });
+});
+
+function createCloseLead(zpContact, done) {
+  const postData = {
+    name: zpContact.organization,
+    status: 'Contacted',
+    contacts: [
+      {
+        name: zpContact.name,
+        title: zpContact.title,
+        emails: [{email: zpContact.email}]
+      }
+    ],
+    custom: {
+      lastUpdated: new Date(),
+      'Lead Origin': 'outbound campaign'
+    }
+  };
+  if (zpContact.phone) {
+    postData.contacts[0].phones = [{phone: zpContact.phone}];
+  }
+  const options = {
+    uri: `https://${closeIoApiKey}:X@app.close.io/api/v1/lead/`,
+    body: JSON.stringify(postData)
+  };
+  request.post(options, (error, response, body) => {
+    if (error) return done(error);
+    const newLead = JSON.parse(body);
+    if (newLead.errors || newLead['field-errors']) {
+      console.error(`New lead POST error for ${zpContact.name} ${zpContact.organization}`);
+      return done(newLead.errors || newLead['field-errors']);
+    }
+    return done();
+  });
+}
+
+function updateCloseLead(zpContact, existingLead, done) {
+  const putData = {
+    status: 'Contacted',
+    'custom.lastUpdated': new Date(),
+    'custom.Lead Origin': 'outbound campaign'
+  };
+  const options = {
+    uri: `https://${closeIoApiKey}:X@app.close.io/api/v1/lead/${existingLead.id}/`,
+    body: JSON.stringify(putData)
+  };
+  request.put(options, (error, response, body) => {
+    if (error) return done(error);
+    const result = JSON.parse(body);
+    if (result.errors || result['field-errors']) {
+      return done(`Update existing lead PUT error for ${existingLead.id} ${zpContact.email} ${result.errors || result['field-errors']}`);
+    }
+    const postData = {
+      lead_id: existingLead.id,
+      name: zpContact.name,
+      title: zpContact.title,
+      emails: [{email: zpContact.email}]
+    };
+    const options = {
+      uri: `https://${closeIoApiKey}:X@app.close.io/api/v1/contact/`,
+      body: JSON.stringify(postData)
+    };
+    request.post(options, (error, response, body) => {
+      if (error) return done(error);
+      const result = JSON.parse(body);
+      if (result.errors || result['field-errors']) {
+        return done(`New Contact POST error for ${existingLead.id} ${zpContact.email} ${result.errors || result['field-errors']}`);
+      }
+      return done();
+    });
+  });
+}
+
+function createUpsertCloseLeadFn(zpContact) {
+  return (done) => {
+    // console.log(`DEBUG: createUpsertCloseLeadFn ${zpContact.organization} ${zpContact.email}`);
+    const query = `email:${zpContact.email}`;
+    const url = `https://${closeIoApiKey}:X@app.close.io/api/v1/lead/?query=${encodeURIComponent(query)}`;
+    request.get(url, (error, response, body) => {
+      if (error) return done(error);
+      const data = JSON.parse(body);
+      if (data.total_results != 0) return done();
+      const query = `name:${zpContact.organization}`;
+      const url = `https://${closeIoApiKey}:X@app.close.io/api/v1/lead/?query=${encodeURIComponent(query)}`;
+      request.get(url, (error, response, body) => {
+        if (error) return done(error);
+        const data = JSON.parse(body);
+        if (data.total_results === 0) {
+          console.log(`DEBUG: Creating lead for ${zpContact.organization} ${zpContact.email}`);
+          return createCloseLead(zpContact, done);
+        }
+        else {
+          const existingLead = data.data[0];
+          console.log(`DEBUG: Adding ${zpContact.organization} ${zpContact.email} to ${existingLead.id}`);
+          return updateCloseLead(zpContact, existingLead, done);
+        }
+      });
+    });
+  };
+}
+
+function getZPRepliedContactsPage(contacts, page, done) {
+  // console.log(`DEBUG: Fetching page ${page} ${zpPageSize}...`);
+  const options = {
+    url: `https://www.zenprospect.com/api/v1/contacts/search?codecombat_special_auth_token=${zpAuthToken}&page=${page}&per_page=${zpPageSize}`,
+    headers: {
+      'Accept': 'application/json'
+    }
+  };
+  request.get(options, (err, response, body) => {
+    if (err) return done(err);
+    const data = JSON.parse(body);
+    for (let contact of data.contacts) {
+      if (contact.email_replied) {
+        contacts.push({
+          organization: contact.organization_name,
+          name: contact.name,
+          title: contact.title,
+          email: contact.email,
+          phone: contact.phone,
+          data: contact
+        });
+      }
+    }
+    return done(null, data.pipeline_total);
+  });
+}
+
+function getZPRepliedContacts(done) {
+  // Get first page to get total contact count for parallized page fetches
+  const contacts = [];
+  getZPRepliedContactsPage(contacts, 0, (err, total) => {
+    if (err) return done(err);
+    const createGetZPLeadsPage = (leads, page) => {
+      return (done) => {
+        getZPRepliedContactsPage(leads, page, done);
+      };
+    }
+    const tasks = [];
+    for (let i = 1; (i - 1) * zpPageSize < total; i++) {
+      tasks.push(createGetZPLeadsPage(contacts, i));
+    }
+    async.parallel(tasks, (err, results) => {
+      if (err) return done(err);
+      const emailContactMap = {};
+      for (const contact of contacts) {
+        if (!contact.organization || !contact.name || !contact.title || !contact.email) {
+          console.log(JSON.stringify(contact, null, 2));
+          return done(`DEBUG: missing data for zp contact:`);
+        } 
+        if (!emailContactMap[contact.email]) emailContactMap[contact.email] = contact;
+      }
+      console.log(`${total} total ZP contacts, ${Object.keys(emailContactMap).length} with replies`);
+      return done(null, emailContactMap);
+    });
+  });
+}

From 0f257373bec6331687d6e456e51af6dc0ffa28ca Mon Sep 17 00:00:00 2001
From: "Sara J. Martinez" <sara.j.martinez@gmail.com>
Date: Sat, 18 Jun 2016 04:34:33 -0500
Subject: [PATCH 11/28] Add several translations for Latin American Spanish
 (#3740)

---
 app/locale/es-419.coffee | 82 ++++++++++++++++++++--------------------
 1 file changed, 41 insertions(+), 41 deletions(-)

diff --git a/app/locale/es-419.coffee b/app/locale/es-419.coffee
index 443f55a6e..9e742557f 100644
--- a/app/locale/es-419.coffee
+++ b/app/locale/es-419.coffee
@@ -183,46 +183,46 @@ module.exports = nativeDescription: "Español (América Latina)", englishDescrip
     campaign_old_multiplayer_description: "Reliquias de una era más civilizada. Ninguna simulación es ejecutada para estas arenas multijugador antiguas y sin héroes."
 
   code:
-#    if: "if" # Keywords--these translations show up on hover, so please translate them all, even if it's kind of long. (In the code editor, they will still be in English.)
-#    else: "else"
-#    elif: "else if"
-#    while: "while"
+    if: "si" # Keywords--these translations show up on hover, so please translate them all, even if it's kind of long. (In the code editor, they will still be in English.)
+    else: "otro"
+    elif: "si no"
+    while: "mientras"
 #    loop: "loop"
-#    for: "for"
+    for: "por"
 #    break: "break"
-#    continue: "continue"
-#    pass: "pass"
+    continue: "continuar"
+    pass: "pasar"
 #    return: "return"
-#    then: "then"
-#    do: "do"
-#    end: "end"
-#    function: "function"
-#    def: "define"
-#    var: "variable"
+    then: "entonces"
+    do: "hacer"
+    end: "fin"
+    function: "función"
+    def: "define"
+    var: "variable"
 #    self: "self"
 #    hero: "hero"
 #    this: "this"
-#    or: "or"
-#    "||": "or"
+    or: "o"
+    "||": "o"
     and: "y"
     "&&": "y"
     not: "no"
     "!": "no"
-#    "=": "assign"
+    "=": "asigne a"
     "==": "iguala"
     "===": "iguala estrictamente"
     "!=": "no iguala"
 #    "!==": "does not strictly equal"
-#    ">": "is greater than"
-#    ">=": "is greater than or equal"
-#    "<": "is less than"
-#    "<=": "is less than or equal"
-#    "*": "multiplied by"
-#    "/": "divided by"
-    "+": "mas"
+    ">": "es mayor que"
+    ">=": "es mayor que o igual"
+    "<": "es menor que"
+    "<=": "es menor que o igual"
+    "*": "multiplicado por"
+    "/": "dividido por"
+    "+": "más"
     "-": "menos"
-#    "+=": "add and assign"
-#    "-=": "subtract and assign"
+    "+=": "añade y asigne"
+    "-=": "elimine y asigne"
     True: "Verdadero"
     true: "verdadero"
     False: "Falso"
@@ -425,7 +425,7 @@ module.exports = nativeDescription: "Español (América Latina)", englishDescrip
     victory_new_item: "Objeto Nuevo"
     victory_viking_code_school: "¡Changos macacos!, el nivel que acabas de pasar era dificil! Si todavía no eres un desarrollador de software, deberías serlo. Acabas de conseguir una aceptación por vía rápida con la Escuela Vikinga de Có, donde tú puedes llevar tus habilidades al siguiente nivel y convertirteen un desarrollador web profesional en 14 semanas."
     victory_become_a_viking: "Conviértete en un Vikingo"
-#    victory_no_progress_for_teachers: "El progreso no es guardado para maestros. But, you can add a student account to your classroom for yourself."
+    victory_no_progress_for_teachers: "El progreso no es guardado para maestros. Pero puede añadir cuenta de estudiante a su aula, por su mismo."
     guide_title: "Guía"
     tome_cast_button_run: "Ejecutar"
     tome_cast_button_running: "Ejecutando"
@@ -512,14 +512,14 @@ module.exports = nativeDescription: "Español (América Latina)", englishDescrip
 #    tip_adding_orgres: "Rounding up ogros."
     tip_sharpening_swords: "Afilando las espadas."
 #    tip_ratatouille: "You must not let anyone define your limits because of where you come from. Your only limit is your soul. - Gusteau, Ratatouille"
-#    tip_nemo: "Cuando la vida gets you down, want to know what you've gotta do? Just keep swimming, just keep swimming. - Dory, Finding Nemo"
+    tip_nemo: "¿Cuando huye la suerte, sabes que hay que hacer? Sigue nadando, sigue nadando. - Dory, Finding Nemo"
 #    tip_internet_weather: "Just move to the internet, it's great here. We get to live inside where the weather is always awesome. - John Green"
 #    tip_nerds: "Nerds are allowed to love stuff, like jump-up-and-down-in-the-chair-can't-control-yourself love it. - John Green"
 #    tip_self_taught: "I taught myself 90% of what I've learned. And that's normal! - Hank Green"
-#    tip_luna_lovegood: "No te preocupes, you're just as sane as I am. - Luna Lovegood"
+    tip_luna_lovegood: "No te preocupes, estas tan cuerdo como yo. - Luna Lovegood"
 #    tip_good_idea: "The best way to have a good idea is to have a lot of ideas. - Linus Pauling"
 #    tip_programming_not_about_computers: "La ciencia cpomputacional is no more about computers than astronomy is about telescopes. - Edsger Dijkstra"
-    tip_mulan: "Cree que puedes, y entonces lo harás. - Mulan"
+    tip_mulan: "Si crees que puedes, entonces lo harás. - Mulan"
 
   game_menu:
     inventory_tab: "Inventario"
@@ -855,7 +855,7 @@ module.exports = nativeDescription: "Español (América Latina)", englishDescrip
     thanks_header: "¡Gracias por solicitar un presupuesto!" # {change}
     thanks_sub_header: "Gracias por el interés de su institución en CodeCombat" #"Gracias por expressing interest in CodeCombat for your school."
     thanks_p: "Estaremos en contacto pronto. ¿Preguntas? Escríbenos:" # {change}
-    back_to_classes: "Volver a las clases"#"Back to Clases"
+    back_to_classes: "Volver a las clases" #"Back to Clases"
     finish_signup: "Termine la creación de su cuenta de maestro:"
     finish_signup_p: "Crear una cuenta para configurar la clase, agregar estudiante y monitorear su progreso a medida que aprenden programacioón"#"Create an account to set up a class, add your students, and monitor their progress as they learn computer science."
     signup_with: "Registrarse con:"
@@ -866,7 +866,7 @@ module.exports = nativeDescription: "Español (América Latina)", englishDescrip
 #    create_account_subtitle: "Get access to teacher-only tools for using CodeCombat in the classroom.  <strong>Set up a class</strong>, add your students, and <strong>monitor their progress</strong>!"
 #    convert_account_title: "Update to Teacher Account"
     not: "No"
-#    setup_a_class: "Set Up a Class"
+    setup_a_class: "Crear un clase"
 
   versions:
     save_version_title: "Guardar nueva versión"
@@ -1266,11 +1266,11 @@ module.exports = nativeDescription: "Español (América Latina)", englishDescrip
 #    changelog: "View latest changes to course levels."
     select_language: "Seleccione lenguaje"
     select_level: "Seleccione nivel"
-#    play_level: "Play Nivel"
+    play_level: "Juga Nivel"
     concepts_covered: "Conceptos Cubiertos"
 #    print_guide: "Print Guide (PDF)"
 #    view_guide_online: "View Guide Online (PDF)"
-#    last_updated: "Last updated:"
+    last_updated: "Ultima revisión:"
 #    grants_lifetime_access: "Grants access to all Courses."
 #    enrollment_credits_available: "Enrollment Credits Available:"
     description: "Descripción" # ClassroomSettingsModal
@@ -1287,19 +1287,19 @@ module.exports = nativeDescription: "Español (América Latina)", englishDescrip
 #    avg_student_exp_advanced: "Advanced - extensive experience with typed code"
 #    avg_student_exp_varied: "Varied Levels of Experience"
 #    student_age_range_label: "Student Age Range"
-#    student_age_range_younger: "Younger than 6"
-#    student_age_range_older: "Older than 18"
+    student_age_range_younger: "Menor que than 6"
+    student_age_range_older: "Mayor que 18"
 #    student_age_range_to: "to"
     create_class: "Crear Grupo"
-#    class_name: "Class Name"
+    class_name: "Nombre de clase"
 #    teacher_account_restricted: "Your account is a teacher account, and so cannot access student content."
 
   teacher:
 #    teacher_dashboard: "Teacher Dashboard" # Navbar
 #    my_classes: "My Classes"
 #    courses: "Courses"
-#    enrollments: "Enrollments"
-#    resources: "Resources"
+    enrollments: "Recursos"
+    resources: "Resources"
     help: "Ayuda"
 #    students: "Students" # Shared
     language: "Lenguaje"
@@ -1307,9 +1307,9 @@ module.exports = nativeDescription: "Español (América Latina)", englishDescrip
 #    complete: "Complete"
 #    access_restricted: "Account Update Required"
 #    teacher_account_required: "A teacher account is required to access this content."
-#    create_teacher_account: "Create Teacher Account"
-#    what_is_a_teacher_account: "What's a Teacher Account?"
-#    teacher_account_explanation: "A CodeCombat Teacher account allows you to set up classrooms, monitor students’ progress as they work through courses, manage enrollments and access resources to aid in your curriculum-building."
+    create_teacher_account: "Crear Cuenta de Maestra"
+    what_is_a_teacher_account: "Cuál es una Cuenta de Maestra?"
+#    teacher_account_explanation: "Una Cuenta de Maestra en CodeCombat Teacher da permiso a crear grupo, monitor students’ progress as they work through courses, manage enrollments and access resources to aid in your curriculum-building."
 #    current_classes: "Current Classes"
 #    archived_classes: "Archived Classes"
 #    archived_classes_blurb: "Classes can be archived for future reference. Unarchive a class to view it in the Current Classes list again."

From 8a955d22f3a755a8013e9bd9797097a59a568723 Mon Sep 17 00:00:00 2001
From: zeinamakky <zeemakky@gmail.com>
Date: Sat, 18 Jun 2016 04:35:47 -0500
Subject: [PATCH 12/28] translated some of the phrases (#3741)

---
 app/locale/fr.coffee | 82 ++++++++++++++++++++++----------------------
 1 file changed, 41 insertions(+), 41 deletions(-)

diff --git a/app/locale/fr.coffee b/app/locale/fr.coffee
index 14839df57..3a0a45fc4 100644
--- a/app/locale/fr.coffee
+++ b/app/locale/fr.coffee
@@ -79,7 +79,7 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
     teachers: "Enseignants!"
     teachers_and_educators: "Enseignants et éducateurs"
     class_in_box: "Apprenez comment notre plateforme classe-tout-inclus s'adapte à votre curriculum."
-#    get_started: "Get Started"
+    get_started: "Commencer"
     students: "Étudiants:"
     join_class: "Joindre une classe"
     role: "Votre rôle:"
@@ -515,9 +515,9 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
     tip_nemo: "Quand la vie vous rabaisse, vous savez ce que vous devenez faire ? Juste continuer de nager, juste continuer de nager. - Dory, Finding Nemo"
     tip_internet_weather: "Just move to the internet, it's great here. We get to live inside where the weather is always awesome. - John Green"
 #    tip_nerds: "Nerds are allowed to love stuff, like jump-up-and-down-in-the-chair-can't-control-yourself love it. - John Green"
-#    tip_self_taught: "I taught myself 90% of what I've learned. And that's normal! - Hank Green"
-#    tip_luna_lovegood: "Don't worry, you're just as sane as I am. - Luna Lovegood"
-#    tip_good_idea: "The best way to have a good idea is to have a lot of ideas. - Linus Pauling"
+    tip_self_taught: "Je me suis enseigné 90% de ce que j'ai appris. Et c'est normal! - Hank Green"
+    tip_luna_lovegood: "Ne t'en fais pas, tu es aussi sain que moi. - Luna Lovegood"
+    tip_good_idea: "La meilleure façon d'avoir une bonne idée est d'avoir beaucoup d'idées. - Linus Pauling"
 #    tip_programming_not_about_computers: "Computer Science is no more about computers than astronomy is about telescopes. - Edsger Dijkstra"
 #    tip_mulan: "Believe you can, then you will. - Mulan"
 
@@ -585,7 +585,7 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
     feature5: "Tutoriels vidéo"
     feature6: "Assitance par e-mail dédiée"
     feature7: "<strong>Clans</strong> privés"
-#    feature8: "<strong>No ads!</strong>"
+    feature8: "<strong>Sans pubs!</strong>"
     free: "Gratuit"
     month: "mois"
     must_be_logged: "Vous devez être identifié. Veuillez créer un compte ou vous identifier depuis le menu ci-dessus."
@@ -725,16 +725,16 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
     editor_config_behaviors_description: "Ferme automatiquement les accolades, parenthèses, et chaînes de caractères."
 
   about:
-#    main_title: "If you want to learn to program, you need to write (a lot of) code."
-#    main_description: "At CodeCombat, our job is to make sure you're doing that with a smile on your face."
-#    mission_link: "Mission"
-#    team_link: "Team"
-#    story_link: "Story"
-#    press_link: "Press"
-#    mission_title: "Our mission: make programming accessible to every student on Earth."
-#    mission_description_1: "<strong>Programming is magic</strong>. It's the ability to create things from pure imagination. We started CodeCombat to give learners the feeling of wizardly power at their fingertips by using <strong>typed code</strong>."
+    main_title: "Si tu veux apprendre la programmation, tu dois écrire beaucoup de code."
+    main_description: "Chez CodeCombat, notre but est d'assurer que tu le fasses avec un sourire."
+    mission_link: "Mission"
+    team_link: "Equipe"
+    story_link: "Histoire"
+    press_link: "Presse"
+    mission_title: "Notre mission: faire en sorte que la programmation soit accessible à chaque élève sur la Terre."
+#    mission_description_1: "<strong>Programming is magic.</strong>. It's the ability to create things from pure imagination. We started CodeCombat to give learners the feeling of wizardly power at their fingertips by using <strong>typed code</strong>."
 #    mission_description_2: "As it turns out, that enables them to learn faster too. WAY faster. It's like having a conversation instead of reading a manual. We want to bring that conversation to every school and to <strong>every student</strong>, because everyone should have the chance to learn the magic of programming."
-#    team_title: "Meet the CodeCombat team"
+    team_title: "Rencontrez l'équipe CodeCombat."
 #    team_values: "We value open and respectful dialog, where the best idea wins. Our decisions are grounded in customer research and our process is focused on delivering tangible results for them. Everyone is hands-on, from our CEO to our Github contributors, because we value growth and learning in our team."
     nick_title: "Programmeur" # {change}
     nick_blurb: "Gourou de Motivation"
@@ -975,7 +975,7 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
     social_facebook: "Aimer CodeCombat sur Facebook"
     social_twitter: "Suivre CodeCombat sur Twitter"
     social_gplus: "Rejoindre CodeCombat sur Google+"
-#    social_slack: "Chat with us in the public CodeCombat Slack channel"
+    social_slack: "Bavardez avec nous sur la chaîne publique Slack de CodeCombat."
     contribute_to_the_project: "Contribuer au projet"
 
   clans:
@@ -1027,15 +1027,15 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
 #    track_concepts1: "Track concepts"
     track_concepts2a: "appris par chaque élèves"
     track_concepts2b: "appris par chaque membres"
-    track_concepts3a: "Suivre les niveaux complétés par chaque élèves"
-    track_concepts3b: "Suivre les niveaux complétés par chaque membres"
-#    track_concepts4a: "See your students'"
-#    track_concepts4b: "See your members'"
-    track_concepts5: "solutions"
+    track_concepts3a: "Suivre les niveaux complétés par chaque élève"
+    track_concepts3b: "Suivre les niveaux complétés par chaque membre"
+    track_concepts4a: "Voir vos élèves"
+    track_concepts4b: "Voir vos membres'"
+    track_concepts5: "Solutions"
     track_concepts6a: "Classer les élèves par nom ou avancement"
     track_concepts6b: "Classer les membres par nom ou avancement"
     track_concepts7: "Nécessite une invitation"
-#    track_concepts8: "to join"
+    track_concepts8: "Joindre"
     private_require_sub: "Les clans privés nécessitent un abonnement pour être créés ou rejoins."
 
   courses:
@@ -1089,7 +1089,7 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
     purchasing_for: "Vous achetez une licence pour"
     creating_for: "Vous créez une classe pour"
     for: "pour" # Like in 'for 30 students'
-#    receive_code: "Afterwards you will receive an unlock code to distribute to your students, which they can use to enroll in your class."
+    receive_code: "Après, vous recevrez un code à distribuer à vos élèves, pour qu'ils puissent s'inscrir dans votre cours."
     free_trial: "Essai gratuit pour les professeurs !"
     get_access: "pour obtenir un accès individuel à tous les cours pour évaluation."
     questions: "Questions?"
@@ -1101,8 +1101,8 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
     no_experience: "Aucune expérience en développement requise"
     easy_monitor: "Suivez facilement la progression des élèves"
     purchase_for_class: "Achetez un cours pour votre classe. C'est très simple de rajouter vos élèves !"
-#    see_the: "See the"
-    more_info: "pour plus d'informations."
+    see_the: "Voir"
+    more_info: "Pour plus d'informations."
     choose_course: "Choisissez votre cours:"
     enter_code: "Entrez un code de déverouillage pour rejoindre une classe existante"
     enter_code1: "Entrez le code de déverouillage"
@@ -1125,7 +1125,7 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
     change_language: "Changez la langue du cours"
     keep_using: "Continuer à utiliser"
     switch_to: "Changer"
-#    greetings: "Greetings!"
+    greetings: "Salutations!"
     back_classrooms: "Retour à mes classes"
     back_courses: "Retour à mes cours"
     edit_details: "Modifier les informations de la classe"
@@ -1162,7 +1162,7 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
     play_now_learn_3: "des chaines de caractères & des variables pour personnaliser des actions"
     play_now_learn_4: "comment battre un ogre (compétence importante dans la vie !)"
     welcome_to_page: "Bienvenu sur la page des Cours !"
-#    completed_hoc: "Amazing! You've completed the Hour of Code course!"
+    completed_hoc: "Génial! Tu as fini le cours Heur de Codage!"
     ready_for_more_header: "Motivé pour plus ? Jouez au mode campagne !"
     ready_for_more_1: "Utilisez des gemmes pour débloquer de nouveaux acessoires !"
     ready_for_more_2: "Jouez à travers de nouveaux mondes et challenges"
@@ -1182,7 +1182,7 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
     play_arena: "Jouer à l'Arene"
     start: "Démarrer"
     last_level: "Dernier Niveau"
-#    welcome_to_hoc: "Adventurers, welcome to our Hour of Code!"
+    welcome_to_hoc: "Aventuriers, bienvenu à note heur de codage!"
     logged_in_as: "Connecté en tant que :"
     not_you: "Pas vous ?"
     welcome_back: "Salut aventurier, content de te revoir !"
@@ -1259,17 +1259,17 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
     add_students1: "ajouter des élèves"
     view_edit: "voir/modifier"
     students_enrolled: "élèves inscrits"
-#    students_assigned: "students assigned"
+    students_assigned: "élèves attribués"
     length: "Durée:"
     title: "Titre" # Flat style redesign
 #    subtitle: "Review course guidelines, solutions, and levels"
 #    changelog: "View latest changes to course levels."
-#    select_language: "Select language"
-#    select_level: "Select level"
+    select_language: "Selectionner langue"
+    select_level: "Selectionner niveau"
 #    play_level: "Play Level"
     concepts_covered: "Conceptes Couverts"
-#    print_guide: "Print Guide (PDF)"
-#    view_guide_online: "View Guide Online (PDF)"
+    print_guide: "Imprimer Guide (PDF)"
+    view_guide_online: "Voir Guide En Ligne (PDF)"
 #    last_updated: "Last updated:"
 #    grants_lifetime_access: "Grants access to all Courses."
 #    enrollment_credits_available: "Enrollment Credits Available:"
@@ -1311,15 +1311,15 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
 #    what_is_a_teacher_account: "What's a Teacher Account?"
 #    teacher_account_explanation: "A CodeCombat Teacher account allows you to set up classrooms, monitor students’ progress as they work through courses, manage enrollments and access resources to aid in your curriculum-building."
 #    current_classes: "Current Classes"
-#    archived_classes: "Archived Classes"
+    archived_classes: "Cours Archivés"
 #    archived_classes_blurb: "Classes can be archived for future reference. Unarchive a class to view it in the Current Classes list again."
-#    view_class: "view class"
-#    archive_class: "archive class"
-#    unarchive_class: "unarchive class"
-#    unarchive_this_class: "Unarchive this class"
-#    no_students_yet: "This class has no students yet."
-#    add_students: "Add Students"
-#    create_new_class: "Create a New Class"
+    view_class: "Voir Cours"
+    archive_class: "Archiver Cours"
+    unarchive_class: "Désarchiver cour"
+    unarchive_this_class: "Désarchiver ce cours"
+    no_students_yet: "Ce cours n'a pas encore d'élèves."
+    add_students: "Ajourter Elèves"
+    create_new_class: "Créer une Nouveau Cour"
 #    class_overview: "Class Overview" # View Class page
 #    avg_playtime: "Average level playtime"
 #    total_playtime: "Total play time"
@@ -1398,7 +1398,7 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
     ambassador_title: "Ambassadeur"
     ambassador_title_description: "(Aide)"
     ambassador_summary: "Domptez les membres du forum, et guidez ceux qui ont besoin d'aide. Nos ambassadeurs représentent CodeCombat face au monde."
-#    teacher_title: "Teacher"
+    teacher_title: "Professeur"
 
   editor:
     main_title: "Éditeurs CodeCombat"

From b758b531d85de5012357c3e9f82a2d469a9e0ca9 Mon Sep 17 00:00:00 2001
From: Imperadeiro98 <Imperadeiro98@users.noreply.github.com>
Date: Sat, 18 Jun 2016 10:36:30 +0100
Subject: [PATCH 13/28] Uncomment an header from fr.coffee

---
 app/locale/fr.coffee | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/locale/fr.coffee b/app/locale/fr.coffee
index 3a0a45fc4..a8abf7101 100644
--- a/app/locale/fr.coffee
+++ b/app/locale/fr.coffee
@@ -1294,7 +1294,7 @@ module.exports = nativeDescription: "français", englishDescription: "French", t
 #    class_name: "Class Name"
 #    teacher_account_restricted: "Your account is a teacher account, and so cannot access student content."
 
-#  teacher:
+  teacher:
 #    teacher_dashboard: "Teacher Dashboard" # Navbar
 #    my_classes: "My Classes"
 #    courses: "Courses"

From cd30e4d08396d7e4f12250147c1e8a5286389528 Mon Sep 17 00:00:00 2001
From: Matt Lott <mattlott@live.com>
Date: Sat, 18 Jun 2016 23:21:06 -0700
Subject: [PATCH 14/28] Send sales auto mails from same user per lead

---
 scripts/addZenProspectLeadsToClose.js |  8 ++-
 scripts/updateCloseIoLeads.js         | 80 ++++++++++++++++++---------
 2 files changed, 61 insertions(+), 27 deletions(-)

diff --git a/scripts/addZenProspectLeadsToClose.js b/scripts/addZenProspectLeadsToClose.js
index 15d2989ef..65d31c56c 100644
--- a/scripts/addZenProspectLeadsToClose.js
+++ b/scripts/addZenProspectLeadsToClose.js
@@ -29,7 +29,7 @@ getZPRepliedContacts((err, emailContactMap) => {
   }
   async.parallel(tasks, (err, results) => {
     if (err) console.log(err);
-    console.log("Script runtime: " + (new Date() - scriptStartTime));
+    log("Script runtime: " + (new Date() - scriptStartTime));
   });
 });
 
@@ -183,8 +183,12 @@ function getZPRepliedContacts(done) {
         } 
         if (!emailContactMap[contact.email]) emailContactMap[contact.email] = contact;
       }
-      console.log(`${total} total ZP contacts, ${Object.keys(emailContactMap).length} with replies`);
+      log(`${total} total ZP contacts, ${Object.keys(emailContactMap).length} with replies`);
       return done(null, emailContactMap);
     });
   });
 }
+
+function log(str) {
+  console.log(new Date().toISOString() + " " + str);
+}
diff --git a/scripts/updateCloseIoLeads.js b/scripts/updateCloseIoLeads.js
index 7753fa320..8cf5f8d18 100644
--- a/scripts/updateCloseIoLeads.js
+++ b/scripts/updateCloseIoLeads.js
@@ -27,7 +27,7 @@ const customFieldsToRemove = [
 ];
 
 // Skip these problematic leads
-const leadsToSkip = ['6 sınıflar', 'fdsafd', 'ashtasht', 'matt+20160404teacher3 school', 'sdfdsf', 'ddddd', 'dsfadsaf', "Nolan's School of Wonders"];
+const leadsToSkip = ['6 sınıflar', 'fdsafd', 'ashtasht', 'matt+20160404teacher3 school', 'sdfdsf', 'ddddd', 'dsfadsaf', "Nolan's School of Wonders", 'asdfsadf'];
 
 const createTeacherEmailTemplatesAuto1 = ['tmpl_i5bQ2dOlMdZTvZil21bhTx44JYoojPbFkciJ0F560mn', 'tmpl_CEZ9PuE1y4PRvlYiKB5kRbZAQcTIucxDvSeqvtQW57G'];
 const demoRequestEmailTemplatesAuto1 = ['tmpl_s7BZiydyCHOMMeXAcqRZzqn0fOtk0yOFlXSZ412MSGm', 'tmpl_cGb6m4ssDvqjvYd8UaG6cacvtSXkZY3vj9b9lSmdQrf'];
@@ -624,7 +624,7 @@ class CocoLead {
 
 // ** Upsert Close.io methods
 
-function updateExistingLead(lead, existingLead, done) {
+function updateExistingLead(lead, existingLead, userApiKeyMap, done) {
   // console.log('DEBUG: updateExistingLead', existingLead.id);
   const putData = lead.getLeadPutData(existingLead);
   const options = {
@@ -646,7 +646,7 @@ function updateExistingLead(lead, existingLead, done) {
     const tasks = []
     for (const newContact of newContacts) {
       newContact.lead_id = existingLead.id;
-      tasks.push(createAddContactFn(newContact, lead, existingLead));
+      tasks.push(createAddContactFn(newContact, lead, existingLead, userApiKeyMap));
     }
     async.parallel(tasks, (err, results) => {
       if (err) return done(err);
@@ -737,7 +737,7 @@ function createFindExistingLeadFn(email, name, existingLeads) {
   };
 }
 
-function createUpdateLeadFn(lead, existingLeads) {
+function createUpdateLeadFn(lead, existingLeads, userApiKeyMap) {
   return (done) => {
     // console.log('DEBUG: updateLead', lead.name);
     const query = `name:"${lead.name}"`;
@@ -750,7 +750,7 @@ function createUpdateLeadFn(lead, existingLeads) {
           if (existingLeads[lead.name.toLowerCase()]) {
             if (existingLeads[lead.name.toLowerCase()].length === 1) {
               // console.log(`DEBUG: Using lead from email lookup: ${lead.name}`);
-              return updateExistingLead(lead, existingLeads[lead.name.toLowerCase()][0], done);
+              return updateExistingLead(lead, existingLeads[lead.name.toLowerCase()][0], userApiKeyMap, done);
             }
             console.error(`ERROR: ${existingLeads[lead.name.toLowerCase()].length} email leads found for ${lead.name}`);
             return done();
@@ -761,7 +761,7 @@ function createUpdateLeadFn(lead, existingLeads) {
           console.error(`ERROR: ${data.total_results} leads found for ${lead.name}`);
           return done();
         }
-        return updateExistingLead(lead, data.data[0], done);
+        return updateExistingLead(lead, data.data[0], userApiKeyMap, done);
       } catch (error) {
         // console.log(url);
         console.log(`ERROR: updateLead ${error}`);
@@ -772,9 +772,11 @@ function createUpdateLeadFn(lead, existingLeads) {
   };
 }
 
-function createAddContactFn(postData, internalLead, externalLead) {
+function createAddContactFn(postData, internalLead, closeIoLead, userApiKeyMap) {
   return (done) => {
     // console.log('DEBUG: addContact', postData.lead_id);
+
+    // Create new contact
     const options = {
       uri: `https://${closeIoApiKey}:X@app.close.io/api/v1/contact/`,
       body: JSON.stringify(postData)
@@ -788,11 +790,20 @@ function createAddContactFn(postData, internalLead, externalLead) {
         return done();
       }
 
-      // Send emails to new contact
-      const email = postData.emails[0].email;
-      const countryCode = getCountryCode(internalLead.contacts[email].trial.properties.country, [email]);
-      const emailTemplate = getEmailTemplate(internalLead.contacts[email].trial.properties.siteOrigin, externalLead.status_label);
-      sendMail(email, externalLead.id, newContact.id, emailTemplate, getEmailApiKey(externalLead.status_label), emailDelayMinutes, done);
+      // Find previous internal user for new contact correspondence
+      const url = `https://${closeIoApiKey}:X@app.close.io/api/v1/activity/email/?lead_id=${closeIoLead.id}`;
+      request.get(url, (error, response, body) => {
+        if (error) return done(error);
+        const data = JSON.parse(body);
+        let emailApiKey = data.data && data.data.length > 0 ? userApiKeyMap[data.data[0].user_id] : getEmailApiKey(closeIoLead.status_label);
+        if (!emailApiKey) emailApiKey = getEmailApiKey(closeIoLead.status_label);
+
+        // Send email to new contact
+        const email = postData.emails[0].email;
+        const countryCode = getCountryCode(internalLead.contacts[email].trial.properties.country, [email]);
+        const emailTemplate = getEmailTemplate(internalLead.contacts[email].trial.properties.siteOrigin, closeIoLead.status_label);
+        sendMail(email, closeIoLead.id, newContact.id, emailTemplate, emailApiKey, emailDelayMinutes, done);
+      });
     });
   };
 }
@@ -883,25 +894,44 @@ function sendMail(toEmail, leadId, contactId, template, emailApiKey, delayMinute
 }
 
 function updateLeads(leads, done) {
-  // Lookup existing leads via email to protect against direct lead name querying later
-  // Querying via lead name is unreliable
-  const existingLeads = {};
-  const tasks = [];
-  for (const name in leads) {
-    if (leadsToSkip.indexOf(name) >= 0) continue;
-    for (const email in leads[name].contacts) {
-      tasks.push(createFindExistingLeadFn(email.toLowerCase(), name.toLowerCase(), existingLeads));
-    }
+  const userApiKeyMap = {};
+  let createGetUserFn = (apiKey) => {
+    return (done) => {
+      const url = `https://${apiKey}:X@app.close.io/api/v1/me/`;
+      request.get(url, (error, response, body) => {
+        if (error) return done();
+        const results = JSON.parse(body);
+        userApiKeyMap[results.id] = apiKey;
+        return done();
+      });
+    };
   }
-  async.series(tasks, (err, results) => {
-    if (err) return done(err);
+  const tasks = [];
+  for (const closeIoMailApiKey of closeIoMailApiKeys) {
+    tasks.push(createGetUserFn(closeIoMailApiKey.apiKey));
+  }
+  async.parallel(tasks, (err, results) => {
+    if (err) console.log(err);
+    // Lookup existing leads via email to protect against direct lead name querying later
+    // Querying via lead name is unreliable
+    const existingLeads = {};
     const tasks = [];
     for (const name in leads) {
       if (leadsToSkip.indexOf(name) >= 0) continue;
-      tasks.push(createUpdateLeadFn(leads[name], existingLeads));
+      for (const email in leads[name].contacts) {
+        tasks.push(createFindExistingLeadFn(email.toLowerCase(), name.toLowerCase(), existingLeads));
+      }
     }
     async.series(tasks, (err, results) => {
-      return done(err);
+      if (err) return done(err);
+      const tasks = [];
+      for (const name in leads) {
+        if (leadsToSkip.indexOf(name) >= 0) continue;
+        tasks.push(createUpdateLeadFn(leads[name], existingLeads, userApiKeyMap));
+      }
+      async.series(tasks, (err, results) => {
+        return done(err);
+      });
     });
   });
 }

From d556e8c153a5be66b5d7bd9329a44f4c3b7d603a Mon Sep 17 00:00:00 2001
From: Dennis Ideler <ideler.dennis@gmail.com>
Date: Sun, 19 Jun 2016 16:06:00 +0100
Subject: [PATCH 15/28] Split long lines for readability

Try to adhere to 80 char limit per line where possible.

Note that internal links are now relative instead of absolute. Besides
being more terse, it has the added benefit that they will still work in
case of a project or organization rename.
---
 README.md | 44 +++++++++++++++++++++++++++++++++-----------
 1 file changed, 33 insertions(+), 11 deletions(-)

diff --git a/README.md b/README.md
index 3a9992acd..b2db3efc7 100644
--- a/README.md
+++ b/README.md
@@ -1,26 +1,48 @@
-#CodeCombat
+# CodeCombat
 
-<div style="text-align:center"><a href="http://codecombat.com/"><img src ="https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/readme_00.png" /></a></div>
+<div style="text-align:center">
+  <a href="http://codecombat.com/">
+    <img src ="https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/readme_00.png" />
+  </a>
+</div>
 [![Build Status](https://travis-ci.org/codecombat/codecombat.png?branch=master)](https://travis-ci.org/codecombat/codecombat)
 
-CodeCombat is a multiplayer programming game for learning how to code. **See the [Archmage (coder) developer wiki](https://github.com/codecombat/codecombat/wiki/Archmage-Home) for a dev setup guide, extensive documentation, and much more. Every new person that wants to start contributing the project coding should start there.**
+CodeCombat is a multiplayer programming game for learning how to code.
+**See the [Archmage (coder) developer wiki](../../wiki/Archmage-Home) for a dev
+setup guide, extensive documentation, and much more. Every new person that wants
+to start contributing the project coding should start there.**
 
-It's both a startup and a community project, completely open source under the [MIT and Creative Commons licenses](http://codecombat.com/legal). It's the largest open source [CoffeeScript](http://coffeescript.org/) project by lines of code, and since it's a game (with [really cool tech](https://github.com/codecombat/codecombat/wiki/Third-party-software-and-services)), it's really fun to hack on. Join us in teaching the world to code! Your contribution will go on to show millions of players how cool programming can be.
+It's both a startup and a community project, completely open source under the
+[MIT and Creative Commons licenses](http://codecombat.com/legal). It's the
+largest open source [CoffeeScript](http://coffeescript.org/) project by lines of
+code, and since it's a game (with [really cool tech](../../wiki/Third-party-software-and-services)),
+it's really fun to hack on. Join us in teaching the world to code! Your
+contribution will go on to show millions of players how cool programming can be.
 
-### [Getting Started](https://github.com/codecombat/codecombat/wiki/Dev-Setup:-General-Information)
+### [Getting Started](../../wiki/Dev-Setup:-General-Information)
 
-We've made it easy to fork the project, run a simple script that'll install all the dependencies, and get a local copy of CodeCombat running right away on [Mac](https://github.com/codecombat/codecombat/wiki/Dev-Setup:-Mac), [Linux](https://github.com/codecombat/codecombat/wiki/Dev-Setup:-Linux), [Windows](https://github.com/codecombat/codecombat/wiki/Dev-Setup:-Windows), or
-[Vagrant](https://github.com/codecombat/codecombat/wiki/Dev-Setup:-Vagrant). See [the docs for details](https://github.com/codecombat/codecombat/wiki/Dev-Setup:-General-Information).
+We've made it easy to fork the project, run a simple script that'll install all
+the dependencies, and get a local copy of CodeCombat running right away on
+[Mac](../../wiki/Dev-Setup:-Mac), [Linux](../../wiki/Dev-Setup:-Linux),
+[Windows](../../wiki/Dev-Setup:-Windows), or [Vagrant](../../wiki/Dev-Setup:-Vagrant).
+See [the docs for details](../../wiki/Dev-Setup:-General-Information).
 
-### [Getting In Touch](https://github.com/codecombat/codecombat/wiki/Developer-organization)
+### [Getting In Touch](../../wiki/Developer-organization)
 
-Whether you're novice or pro, the CodeCombat team is ready to help you implement your ideas. Reach out on our [forum](http://discourse.codecombat.com), our [issue tracker](https://github.com/codecombat/codecombat/issues), or [our developer chat room on Slack](https://coco-slack-invite.herokuapp.com/), or see the docs for [more on how to contribute](https://github.com/codecombat/codecombat/wiki/Developer-organization).
+Whether you're novice or pro, the CodeCombat team is ready to help you implement
+your ideas. Reach out on our [forum](http://discourse.codecombat.com), our
+[issue tracker](../../issues), or
+[our developer chat room on Slack](https://coco-slack-invite.herokuapp.com/), or
+see the docs for [more on how to contribute](../../wiki/Developer-organization).
 
 [![Slack Status](https://coco-slack-invite.herokuapp.com/badge.svg)](https://coco-slack-invite.herokuapp.com/)
 
-### [License](https://github.com/codecombat/codecombat/blob/master/LICENSE)
+### [License](LICENSE)
 
-[MIT](https://github.com/codecombat/codecombat/blob/master/LICENSE) for the code, and [CC-BY](http://codecombat.com/legal) for the art and music. Please also [sign the CodeCombat contributor license agreement](http://codecombat.com/cla) so we can accept your pull requests. It is easy.
+[MIT](LICENSE) for the code, and [CC-BY](http://codecombat.com/legal) for the
+art and music. Please also
+[sign the CodeCombat contributor license agreement](http://codecombat.com/cla)
+so we can accept your pull requests. It is easy.
 
 ### [Join Us!](http://blog.codecombat.com/why-you-should-open-source-your-startup)
 

From d52e217c26b6994b9fba9a642dfd879cb645c58c Mon Sep 17 00:00:00 2001
From: Dennis Ideler <ideler.dennis@gmail.com>
Date: Sun, 19 Jun 2016 16:20:14 +0100
Subject: [PATCH 16/28] Show names when hovering over avatars

Add titles to avatar images.
---
 README.md | 116 +++++++++++++++++++++++++++---------------------------
 1 file changed, 58 insertions(+), 58 deletions(-)

diff --git a/README.md b/README.md
index b2db3efc7..d6e11e265 100644
--- a/README.md
+++ b/README.md
@@ -46,61 +46,61 @@ so we can accept your pull requests. It is easy.
 
 ### [Join Us!](http://blog.codecombat.com/why-you-should-open-source-your-startup)
 
-![Nick Winter](http://codecombat.com/images/pages/about/nick_small.png)
-![George Saines](http://codecombat.com/images/pages/about/george_small.png)
-![Scott Erickson](http://codecombat.com/images/pages/about/scott_small.png)
-![Matt Lott](http://codecombat.com/images/pages/about/matt_small.png)
-![Catherine Weresow](http://codecombat.com/images/pages/about/cat_small.png)
-![Maka Gradin](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Maka%20Gradin/maka_gradin_100.png)
-![Rob Blanckaert](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Rob%20Blanckaert/rob_blanckaert_100.png)
-![Josh Callebaut](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Josh%20Callebaut/josh_callebaut_100.png)
-![Michael Schmatz](http://codecombat.com/images/pages/about/michael_small.png)
-![Josh Lee](http://codecombat.com/images/pages/about/josh_small.png)
-![Alex Cotsarelis](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Alex%20Cotsarelis/alex_100.png)
-![Alex Crooks](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Alex%20Crooks/alex_100.png)
-![Alexandru Caciulescu](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Alexandru%20Caciulescu/alexandru_100.png)
-![Andreas Linn](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Andreas%20Linn/andreas_100.png)
-![Andrew Witcher](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Andrew%20Witcher/andrew_100.png)
-![Axandre Oge](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Axandre%20Oge/axandre_100.png)
-![Bang Honam](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Bang%20Honam/bang_100.png)
-![Benjamin Stern](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Benjamin%20Stern/benjamin_100.png)
-![Brad Dickason](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Brad%20Dickason/brad_100.png)
-![Carlos Maia](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Carlos%20Maia/carlos_maia_100.png)
-![Chloe Fan](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Chloe%20Fan/chloe_100.png)
-![Dan Ristic](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Dan%20Ristic/dan_100.png)
-![Danny Whittaker](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Danny%20Whittaker/danny_100.png)
-![David Liu](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/David%20Liu/david_liu_100.png)
-![David Pendray](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/David%20Pendray/david_100.png)
-![Deepak1556](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Deepak1556/deepak_100.png)
-![Derek Wong](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Derek%20Wong/derek_100.png)
-![Dominik Kundel](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Dominik%20Kundel/dominik_k_100.png)
-![Glen De Cauwsemaecker](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Glen%20de%20Cauwsemaecker/glen_100.png)
-![Ian Li](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Ian%20Li/ian_100.png)
-![Jeremy Arns](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Jeremy%20Arns/jeremy_100.png)
-![Joachim Brehmer](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Joachim%20Brehmer/joachim_100.png)
-![Jose Antonini](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Jose%20Antonini/jose_antonini_100.png)
-![Katharine Chan](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Katharine%20Chan/katharine_100.png)
-![Ken Stanley](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Ken%20Stanley/ken_100.png)
-![Kevin Holland](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Kevin%20Holland/kevin_100.png)
-![Laura Watiker](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Laura%20Watiker/laura_100.png)
-![Michael Heasell](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Michael%20Heasell/michael_100.png)
-![Michael Polyak](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Michael%20Polyak/michael_100.png)
-![Mischa Lewis-Norelle](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Mischa%20Lewis-Norelle/mischa_100.png)
-![Nathan Gosset](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Nathan%20Gosset/nathan_100.png)
-![Oleg Ulyanicky](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Oleg%20Ulyanickiy/oleg_100.png)
-![Paul Buser](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Paul%20Buser/paul_100.png)
-![Pavel Konstantynov](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Pavel%20Konstantinov/pavel_100.png)
-![Popey Gilbert](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Popey%20Gilbert/popey_100.png)
-![Prabhsimran Baweja](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Prabhsimran%20Baweja/prabhsimran_100.png)
-![Rachel Xiang](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Rachel%20Xiang/rachel_100.png)
-![Rebecca Saines](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Rebecca%20Saines/rebecca_100.png)
-![Robert Moreton](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Robert%20Moreton/robert_100.png)
-![Ronnie Cheng](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Ronnie%20Cheng/ronnie_100.png)
-![Ruben Vereecken](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Ruben%20Vereecken/ruben_100.png)
-![Russ Fan](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Russ%20Fan/russ_100.png)
-![Shiying Zheng](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Shying%20Zheng/shiyeng_100.png)
-![Sébastien Moratinos](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Tom%20Steinbrecher/tom_100.png)
-![Thanish Muhammed](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Thanish%20Muhammed/thanish_100.png)
-![Tom Steinbrecher](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Tom%20Steinbrecher/tom_100.png)
-![Yang Shun Tay](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Yang%20Shun%20Tay/yang_shun_tay_100.png)
-![Zach Martin](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Zach%20Martin/zack_100.png)
+![Nick Winter](http://codecombat.com/images/pages/about/nick_small.png "Nick Winter")
+![George Saines](http://codecombat.com/images/pages/about/george_small.png "George Saines")
+![Scott Erickson](http://codecombat.com/images/pages/about/scott_small.png "Scott Erickson")
+![Matt Lott](http://codecombat.com/images/pages/about/matt_small.png "Matt Lott")
+![Catherine Weresow](http://codecombat.com/images/pages/about/cat_small.png "Catherine Weresow")
+![Maka Gradin](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Maka%20Gradin/maka_gradin_100.png "Maka Gradin")
+![Rob Blanckaert](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Rob%20Blanckaert/rob_blanckaert_100.png "Rob Blanckaert")
+![Josh Callebaut](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Josh%20Callebaut/josh_callebaut_100.png "Josh Callebaut")
+![Michael Schmatz](http://codecombat.com/images/pages/about/michael_small.png "Michael Schmatz")
+![Josh Lee](http://codecombat.com/images/pages/about/josh_small.png "Josh Lee")
+![Alex Cotsarelis](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Alex%20Cotsarelis/alex_100.png "Alex Cotsarelis")
+![Alex Crooks](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Alex%20Crooks/alex_100.png "Alex Crooks")
+![Alexandru Caciulescu](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Alexandru%20Caciulescu/alexandru_100.png "Alexandru Caciulescu")
+![Andreas Linn](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Andreas%20Linn/andreas_100.png "Andreas Linn")
+![Andrew Witcher](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Andrew%20Witcher/andrew_100.png "Andrew Witcher")
+![Axandre Oge](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Axandre%20Oge/axandre_100.png "Axandre Oge")
+![Bang Honam](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Bang%20Honam/bang_100.png "Bang Honam")
+![Benjamin Stern](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Benjamin%20Stern/benjamin_100.png "Benjamin Stern")
+![Brad Dickason](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Brad%20Dickason/brad_100.png "Brad Dickason")
+![Carlos Maia](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Carlos%20Maia/carlos_maia_100.png "Carlos Maia")
+![Chloe Fan](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Chloe%20Fan/chloe_100.png "Chloe Fan")
+![Dan Ristic](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Dan%20Ristic/dan_100.png "Dan Ristic")
+![Danny Whittaker](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Danny%20Whittaker/danny_100.png "Danny Whittaker")
+![David Liu](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/David%20Liu/david_liu_100.png "David Liu")
+![David Pendray](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/David%20Pendray/david_100.png "David Pendray")
+![Deepak1556](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Deepak1556/deepak_100.png "Deepak1556")
+![Derek Wong](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Derek%20Wong/derek_100.png "Derek Wong")
+![Dominik Kundel](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Dominik%20Kundel/dominik_k_100.png "Dominik Kundel")
+![Glen De Cauwsemaecker](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Glen%20de%20Cauwsemaecker/glen_100.png "Glen De Cauwsemaecker")
+![Ian Li](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Ian%20Li/ian_100.png "Ian Li")
+![Jeremy Arns](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Jeremy%20Arns/jeremy_100.png "Jeremy Arns")
+![Joachim Brehmer](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Joachim%20Brehmer/joachim_100.png "Joachim Brehmer")
+![Jose Antonini](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Jose%20Antonini/jose_antonini_100.png "Jose Antonini")
+![Katharine Chan](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Katharine%20Chan/katharine_100.png "Katharine Chan")
+![Ken Stanley](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Ken%20Stanley/ken_100.png "Ken Stanley")
+![Kevin Holland](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Kevin%20Holland/kevin_100.png "Kevin Holland")
+![Laura Watiker](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Laura%20Watiker/laura_100.png "Laura Watiker")
+![Michael Heasell](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Michael%20Heasell/michael_100.png "Michael Heasell")
+![Michael Polyak](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Michael%20Polyak/michael_100.png "Michael Polyak")
+![Mischa Lewis-Norelle](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Mischa%20Lewis-Norelle/mischa_100.png "Mischa Lewis-Norelle")
+![Nathan Gosset](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Nathan%20Gosset/nathan_100.png "Nathan Gosset")
+![Oleg Ulyanicky](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Oleg%20Ulyanickiy/oleg_100.png "Oleg Ulyanicky")
+![Paul Buser](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Paul%20Buser/paul_100.png "Paul Buser")
+![Pavel Konstantynov](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Pavel%20Konstantinov/pavel_100.png "Pavel Konstantynov")
+![Popey Gilbert](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Popey%20Gilbert/popey_100.png "Popey Gilbert")
+![Prabhsimran Baweja](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Prabhsimran%20Baweja/prabhsimran_100.png "Prabhsimran Baweja")
+![Rachel Xiang](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Rachel%20Xiang/rachel_100.png "Rachel Xiang")
+![Rebecca Saines](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Rebecca%20Saines/rebecca_100.png "Rebecca Saines")
+![Robert Moreton](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Robert%20Moreton/robert_100.png "Robert Moreton")
+![Ronnie Cheng](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Ronnie%20Cheng/ronnie_100.png "Ronnie Cheng")
+![Ruben Vereecken](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Ruben%20Vereecken/ruben_100.png "Ruben Vereecken")
+![Russ Fan](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Russ%20Fan/russ_100.png "Russ Fan")
+![Shiying Zheng](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Shying%20Zheng/shiyeng_100.png "Shiying Zheng")
+![Sébastien Moratinos](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Tom%20Steinbrecher/tom_100.png "Sébastien Moratinos")
+![Thanish Muhammed](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Thanish%20Muhammed/thanish_100.png "Thanish Muhammed")
+![Tom Steinbrecher](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Tom%20Steinbrecher/tom_100.png "Tom Steinbrecher")
+![Yang Shun Tay](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Yang%20Shun%20Tay/yang_shun_tay_100.png "Yang Shun Tay")
+![Zach Martin](https://dl.dropboxusercontent.com/u/138899/GitHub%20Wikis/avatars/Zach%20Martin/zack_100.png "Zach Martin")

From e0170d03399e5649eac458bff2a7c472623ff16a Mon Sep 17 00:00:00 2001
From: Matt Lott <mattlott@live.com>
Date: Sun, 19 Jun 2016 20:23:32 -0700
Subject: [PATCH 17/28] Add hero-practice level type and threshold to schema

Filtering out hero-practice levels from classrooms until the Ux
supports them.
---
 app/models/Level.coffee                       |  2 +-
 app/schemas/models/campaign.schema.coffee     |  2 +-
 app/schemas/models/level.coffee               |  3 ++-
 .../component/ThangComponentConfigView.coffee |  2 +-
 .../level/thangs/LevelThangEditView.coffee    |  2 +-
 .../editor/level/thangs/ThangsTabView.coffee  |  4 ++--
 app/views/play/level/PlayLevelView.coffee     |  8 +++----
 .../play/level/modal/HeroVictoryModal.coffee  | 10 ++++-----
 app/views/play/level/tome/Spell.coffee        |  2 +-
 .../level/tome/SpellPaletteEntryView.coffee   |  2 +-
 .../play/level/tome/SpellPaletteView.coffee   |  4 ++--
 app/views/play/level/tome/SpellView.coffee    |  2 +-
 app/views/play/level/tome/TomeView.coffee     |  4 ++--
 server/middleware/classrooms.coffee           |  4 +++-
 spec/server/functional/classrooms.spec.coffee | 21 +++++++++++++++++--
 15 files changed, 46 insertions(+), 26 deletions(-)

diff --git a/app/models/Level.coffee b/app/models/Level.coffee
index 32b238df0..dad6f3106 100644
--- a/app/models/Level.coffee
+++ b/app/models/Level.coffee
@@ -58,7 +58,7 @@ module.exports = class Level extends CocoModel
 
   denormalize: (supermodel, session, otherSession) ->
     o = $.extend true, {}, @attributes
-    if o.thangs and @get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']
+    if o.thangs and @get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice']
       thangTypesWithComponents = (tt for tt in supermodel.getModels(ThangType) when tt.get('components')?)
       thangTypesByOriginal = _.indexBy thangTypesWithComponents, (tt) -> tt.get('original')  # Optimization
       for levelThang in o.thangs
diff --git a/app/schemas/models/campaign.schema.coffee b/app/schemas/models/campaign.schema.coffee
index 182d62771..303937cb5 100644
--- a/app/schemas/models/campaign.schema.coffee
+++ b/app/schemas/models/campaign.schema.coffee
@@ -61,7 +61,7 @@ _.extend CampaignSchema.properties, {
       i18n: { type: 'object', format: 'hidden' }
       requiresSubscription: { type: 'boolean' }
       replayable: { type: 'boolean' }
-      type: {'enum': ['ladder', 'ladder-tutorial', 'hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']}
+      type: {'enum': ['ladder', 'ladder-tutorial', 'hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice']}
       slug: { type: 'string', format: 'hidden' }
       original: { type: 'string', format: 'hidden' }
       adventurer: { type: 'boolean' }
diff --git a/app/schemas/models/level.coffee b/app/schemas/models/level.coffee
index caca6a8b8..6c4ec4491 100644
--- a/app/schemas/models/level.coffee
+++ b/app/schemas/models/level.coffee
@@ -313,7 +313,7 @@ _.extend LevelSchema.properties,
   icon: {type: 'string', format: 'image-file', title: 'Icon'}
   banner: {type: 'string', format: 'image-file', title: 'Banner'}
   goals: c.array {title: 'Goals', description: 'An array of goals which are visible to the player and can trigger scripts.'}, GoalSchema
-  type: c.shortString(title: 'Type', description: 'What kind of level this is.', 'enum': ['campaign', 'ladder', 'ladder-tutorial', 'hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'])
+  type: c.shortString(title: 'Type', description: 'What kind of level this is.', 'enum': ['campaign', 'ladder', 'ladder-tutorial', 'hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice'])
   terrain: c.terrainString
   showsGuide: c.shortString(title: 'Shows Guide', description: 'If the guide is shown at the beginning of the level.', 'enum': ['first-time', 'always'])
   requiresSubscription: {title: 'Requires Subscription', description: 'Whether this level is available to subscribers only.', type: 'boolean'}
@@ -324,6 +324,7 @@ _.extend LevelSchema.properties,
     url: c.url {title: 'URL', description: 'Link to the video on Vimeo.'}
   replayable: {type: 'boolean', title: 'Replayable', description: 'Whether this (hero) level infinitely scales up its difficulty and can be beaten over and over for greater rewards.'}
   buildTime: {type: 'number', description: 'How long it has taken to build this level.'}
+  practiceThresholdMinutes: {type: 'number', description: 'Players with larger playtimes may be directed to a practice level.'}
 
   # Admin flags
   adventurer: { type: 'boolean' }
diff --git a/app/views/editor/component/ThangComponentConfigView.coffee b/app/views/editor/component/ThangComponentConfigView.coffee
index dc5498b41..cd50d2619 100644
--- a/app/views/editor/component/ThangComponentConfigView.coffee
+++ b/app/views/editor/component/ThangComponentConfigView.coffee
@@ -46,7 +46,7 @@ module.exports = class ThangComponentConfigView extends CocoView
     schema.default ?= {}
     _.merge schema.default, @additionalDefaults if @additionalDefaults
 
-    if @level?.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']
+    if @level?.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice']
       schema.required = []
     treemaOptions =
       supermodel: @supermodel
diff --git a/app/views/editor/level/thangs/LevelThangEditView.coffee b/app/views/editor/level/thangs/LevelThangEditView.coffee
index 84429d644..16c6f861a 100644
--- a/app/views/editor/level/thangs/LevelThangEditView.coffee
+++ b/app/views/editor/level/thangs/LevelThangEditView.coffee
@@ -41,7 +41,7 @@ module.exports = class LevelThangEditView extends CocoView
       level: @level
       world: @world
 
-    if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] then options.thangType = thangType
+    if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice'] then options.thangType = thangType
 
     @thangComponentEditView = new ThangComponentsEditView options
     @listenTo @thangComponentEditView, 'components-changed', @onComponentsChanged
diff --git a/app/views/editor/level/thangs/ThangsTabView.coffee b/app/views/editor/level/thangs/ThangsTabView.coffee
index c5ff8c65f..006605470 100644
--- a/app/views/editor/level/thangs/ThangsTabView.coffee
+++ b/app/views/editor/level/thangs/ThangsTabView.coffee
@@ -585,14 +585,14 @@ module.exports = class ThangsTabView extends CocoView
     if batchInsert
       if thangType.get('name') is 'Hero Placeholder'
         thangID = 'Hero Placeholder'
-        return if not (@level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']) or @getThangByID(thangID)
+        return if not (@level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice']) or @getThangByID(thangID)
       else
         thangID = "Random #{thangType.get('name')} #{@thangsBatch.length}"
     else
       thangID = Thang.nextID(thangType.get('name'), @world) until thangID and not @getThangByID(thangID)
     if @cloneSourceThang
       components = _.cloneDeep @getThangByID(@cloneSourceThang.id).components
-    else if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']
+    else if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice']
       components = []  # Load them all from default ThangType Components
     else
       components = _.cloneDeep thangType.get('components') ? []
diff --git a/app/views/play/level/PlayLevelView.coffee b/app/views/play/level/PlayLevelView.coffee
index 249a92ebd..d6c9f7f41 100644
--- a/app/views/play/level/PlayLevelView.coffee
+++ b/app/views/play/level/PlayLevelView.coffee
@@ -205,7 +205,7 @@ module.exports = class PlayLevelView extends RootView
     @session = @levelLoader.session
     @world = @levelLoader.world
     @level = @levelLoader.level
-    @$el.addClass 'hero' if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']
+    @$el.addClass 'hero' if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice']
     @$el.addClass 'flags' if _.any(@world.thangs, (t) -> (t.programmableProperties and 'findFlags' in t.programmableProperties) or t.inventory?.flag) or @level.get('slug') is 'sky-span'
     # TODO: Update terminology to always be opponentSession or otherSession
     # TODO: E.g. if it's always opponent right now, then variable names should be opponentSession until we have coop play
@@ -467,7 +467,7 @@ module.exports = class PlayLevelView extends RootView
     return false if $.browser?.msie or $.browser?.msedge
     return false if $.browser.linux
     return false if me.level() < 8
-    if levelType in ['course', 'game-dev']
+    if levelType in ['course', 'game-dev', 'hero-practice']
       return false
     else if levelType is 'hero' and gamesSimulated
       return false if stillBuggy
@@ -540,7 +540,7 @@ module.exports = class PlayLevelView extends RootView
   onDonePressed: -> @showVictory()
 
   onShowVictory: (e) ->
-    $('#level-done-button').show() unless @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']
+    $('#level-done-button').show() unless @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice']
     @showVictory() if e.showModal
     return if @victorySeen
     @victorySeen = true
@@ -558,7 +558,7 @@ module.exports = class PlayLevelView extends RootView
     return if @level.hasLocalChanges()  # Don't award achievements when beating level changed in level editor
     @endHighlight()
     options = {level: @level, supermodel: @supermodel, session: @session, hasReceivedMemoryWarning: @hasReceivedMemoryWarning, courseID: @courseID, courseInstanceID: @courseInstanceID, world: @world}
-    ModalClass = if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] then HeroVictoryModal else VictoryModal
+    ModalClass = if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice'] then HeroVictoryModal else VictoryModal
     ModalClass = CourseVictoryModal if @isCourseMode() or me.isSessionless()
     ModalClass = PicoCTFVictoryModal if window.serverConfig.picoCTF
     victoryModal = new ModalClass(options)
diff --git a/app/views/play/level/modal/HeroVictoryModal.coffee b/app/views/play/level/modal/HeroVictoryModal.coffee
index 1f7a46672..7ea956c22 100644
--- a/app/views/play/level/modal/HeroVictoryModal.coffee
+++ b/app/views/play/level/modal/HeroVictoryModal.coffee
@@ -49,7 +49,7 @@ module.exports = class HeroVictoryModal extends ModalView
     @session = options.session
     @level = options.level
     @thangTypes = {}
-    if @level.get('type', true) in ['hero', 'hero-ladder', 'course', 'course-ladder', 'game-dev']
+    if @level.get('type', true) in ['hero', 'hero-ladder', 'course', 'course-ladder', 'game-dev', 'hero-practice']
       achievements = new CocoCollection([], {
         url: "/db/achievement?related=#{@session.get('level').original}"
         model: Achievement
@@ -155,7 +155,7 @@ module.exports = class HeroVictoryModal extends ModalView
     c = super()
     c.levelName = utils.i18n @level.attributes, 'name'
     # TODO: support 'game-dev'
-    if @level.get('type', true) not in ['hero', 'game-dev']
+    if @level.get('type', true) not in ['hero', 'game-dev', 'hero-practice']
       c.victoryText = utils.i18n @level.get('victory') ? {}, 'body'
     earnedAchievementMap = _.indexBy(@newEarnedAchievements or [], (ea) -> ea.get('achievement'))
     for achievement in (@achievements?.models or [])
@@ -223,7 +223,7 @@ module.exports = class HeroVictoryModal extends ModalView
 
   afterRender: ->
     super()
-    @$el.toggleClass 'with-achievements', @level.get('type', true) in ['hero', 'hero-ladder', 'game-dev']  # TODO: support game-dev
+    @$el.toggleClass 'with-achievements', @level.get('type', true) in ['hero', 'hero-ladder', 'game-dev', 'hero-practice']  # TODO: support game-dev
     return unless @supermodel.finished()
     @playSelectionSound hero, true for original, hero of @thangTypes  # Preload them
     @updateSavingProgressStatus()
@@ -233,7 +233,7 @@ module.exports = class HeroVictoryModal extends ModalView
       @insertSubView @ladderSubmissionView, @$el.find('.ladder-submission-view')
 
   initializeAnimations: ->
-    return @endSequentialAnimations() unless @level.get('type', true) in ['hero', 'hero-ladder', 'game-dev']  # TODO: support game-dev
+    return @endSequentialAnimations() unless @level.get('type', true) in ['hero', 'hero-ladder', 'game-dev', 'hero-practice']  # TODO: support game-dev
     @updateXPBars 0
     #playVictorySound = => @playSound 'victory-title-appear'  # TODO: actually add this
     @$el.find('#victory-header').delay(250).queue(->
@@ -264,7 +264,7 @@ module.exports = class HeroVictoryModal extends ModalView
 
   beginSequentialAnimations: ->
     return if @destroyed
-    return unless @level.get('type', true) in ['hero', 'hero-ladder', 'game-dev']  # TODO: support game-dev
+    return unless @level.get('type', true) in ['hero', 'hero-ladder', 'game-dev', 'hero-practice']  # TODO: support game-dev
     @sequentialAnimatedPanels = _.map(@animatedPanels.find('.reward-panel'), (panel) -> {
       number: $(panel).data('number')
       previousNumber: $(panel).data('previous-number')
diff --git a/app/views/play/level/tome/Spell.coffee b/app/views/play/level/tome/Spell.coffee
index 71b68fb3d..2b8ee4c32 100644
--- a/app/views/play/level/tome/Spell.coffee
+++ b/app/views/play/level/tome/Spell.coffee
@@ -171,7 +171,7 @@ module.exports = class Spell
     writable = @permissions.readwrite.length > 0 and not @isAISource
     skipProtectAPI = @skipProtectAPI or not writable or @levelType in ['game-dev']
     problemContext = @createProblemContext thang
-    includeFlow = (@levelType in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']) and not skipProtectAPI
+    includeFlow = (@levelType in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice']) and not skipProtectAPI
     aetherOptions = createAetherOptions
       functionName: @name
       codeLanguage: @language
diff --git a/app/views/play/level/tome/SpellPaletteEntryView.coffee b/app/views/play/level/tome/SpellPaletteEntryView.coffee
index cbada6610..b1be1f196 100644
--- a/app/views/play/level/tome/SpellPaletteEntryView.coffee
+++ b/app/views/play/level/tome/SpellPaletteEntryView.coffee
@@ -84,7 +84,7 @@ module.exports = class SpellPaletteEntryView extends CocoView
     Backbone.Mediator.publish 'tome:palette-pin-toggled', entry: @, pinned: @popoverPinned
 
   onClick: (e) =>
-    if true or @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']
+    if true or @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice']
       # Jiggle instead of pin for hero levels
       # Actually, do it all the time, because we recently busted the pin CSS. TODO: restore pinning
       jigglyPopover = $('.spell-palette-popover.popover')
diff --git a/app/views/play/level/tome/SpellPaletteView.coffee b/app/views/play/level/tome/SpellPaletteView.coffee
index 87f7b21f4..de293efba 100644
--- a/app/views/play/level/tome/SpellPaletteView.coffee
+++ b/app/views/play/level/tome/SpellPaletteView.coffee
@@ -157,7 +157,7 @@ module.exports = class SpellPaletteView extends CocoView
     else
       propStorage =
         'this': ['apiProperties', 'apiMethods']
-    if not (@options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']) or not @options.programmable
+    if not (@options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice']) or not @options.programmable
       @organizePalette propStorage, allDocs, excludedDocs
     else
       @organizePaletteHero propStorage, allDocs, excludedDocs
@@ -199,7 +199,7 @@ module.exports = class SpellPaletteView extends CocoView
     if tabbify and _.find @entries, ((entry) -> entry.doc.owner isnt 'this')
       @entryGroups = _.groupBy @entries, groupForEntry
     else
-      i18nKey = if @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev'] then 'play_level.tome_your_skills' else 'play_level.tome_available_spells'
+      i18nKey = if @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice'] then 'play_level.tome_your_skills' else 'play_level.tome_available_spells'
       defaultGroup = $.i18n.t i18nKey
       @entryGroups = {}
       @entryGroups[defaultGroup] = @entries
diff --git a/app/views/play/level/tome/SpellView.coffee b/app/views/play/level/tome/SpellView.coffee
index 75a800446..9cddc2178 100644
--- a/app/views/play/level/tome/SpellView.coffee
+++ b/app/views/play/level/tome/SpellView.coffee
@@ -635,7 +635,7 @@ module.exports = class SpellView extends CocoView
     @createToolbarView()
 
   createDebugView: ->
-    return if @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']  # We'll turn this on later, maybe, but not yet.
+    return if @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice']  # We'll turn this on later, maybe, but not yet.
     @debugView = new SpellDebugView ace: @ace, thang: @thang, spell:@spell
     @$el.append @debugView.render().$el.hide()
 
diff --git a/app/views/play/level/tome/TomeView.coffee b/app/views/play/level/tome/TomeView.coffee
index 98a11b42d..376b54bcd 100644
--- a/app/views/play/level/tome/TomeView.coffee
+++ b/app/views/play/level/tome/TomeView.coffee
@@ -60,7 +60,7 @@ module.exports = class TomeView extends CocoView
     @worker = @createWorker()
     programmableThangs = _.filter @options.thangs, (t) -> t.isProgrammable and t.programmableMethods
     @createSpells programmableThangs, programmableThangs[0]?.world  # Do before spellList, thangList, and castButton
-    unless @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']
+    unless @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice']
       @spellList = @insertSubView new SpellListView spells: @spells, supermodel: @supermodel, level: @options.level
     @castButton = @insertSubView new CastButtonView spells: @spells, level: @options.level, session: @options.session, god: @options.god
     @teamSpellMap = @generateTeamSpellMap(@spells)
@@ -194,7 +194,7 @@ module.exports = class TomeView extends CocoView
     @castButton?.$el.hide()
 
   onSpriteSelected: (e) ->
-    return if @spellView and @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev']  # Never deselect the hero in the Tome.
+    return if @spellView and @options.level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'hero-practice']  # Never deselect the hero in the Tome.
     thang = e.thang
     spellName = e.spellName
     @spellList?.$el.hide()
diff --git a/server/middleware/classrooms.coffee b/server/middleware/classrooms.coffee
index 8a12b3c5a..70cbe1e08 100644
--- a/server/middleware/classrooms.coffee
+++ b/server/middleware/classrooms.coffee
@@ -141,7 +141,7 @@ module.exports =
     classroom.set 'members', []
     database.assignBody(req, classroom)
     
-    # copy over data from how courses are right now
+    # Copy over data from how courses are right now
     courses = yield Course.find()
     campaigns = yield Campaign.find({_id: {$in: (course.get('campaignID') for course in courses)}})
     campaignMap = {}
@@ -151,6 +151,8 @@ module.exports =
       courseData = { _id: course._id, levels: [] }
       campaign = campaignMap[course.get('campaignID').toString()]
       levels = _.values(campaign.get('levels'))
+      # TODO: remove hero-practice filter after classroom Ux supports practice levels
+      levels = _.reject(levels, {'type': 'hero-practice'})
       levels = _.sortBy(levels, 'campaignIndex')
       for level in levels
         levelData = { original: mongoose.Types.ObjectId(level.original) }
diff --git a/spec/server/functional/classrooms.spec.coffee b/spec/server/functional/classrooms.spec.coffee
index b34cb3152..5bdce3f38 100644
--- a/spec/server/functional/classrooms.spec.coffee
+++ b/spec/server/functional/classrooms.spec.coffee
@@ -86,7 +86,14 @@ describe 'POST /db/classroom', ->
     [res, body] = yield request.postAsync({uri: getURL('/db/level'), json: levelJSONB})
     expect(res.statusCode).toBe(200)
     @levelB = yield Level.findById(res.body._id)
+    levelJSONC = { name: 'Level C', permissions: [{access: 'owner', target: admin.id}], type: 'hero-practice' }
+    [res, body] = yield request.postAsync({uri: getURL('/db/level'), json: levelJSONC})
+    expect(res.statusCode).toBe(200)
+    @levelC = yield Level.findById(res.body._id)
     campaignJSON = { name: 'Campaign', levels: {} }
+    paredLevelC = _.pick(@levelC.toObject(), 'name', 'original', 'type', 'slug')
+    paredLevelC.campaignIndex = 2
+    campaignJSON.levels[@levelC.get('original').toString()] = paredLevelC
     paredLevelB = _.pick(@levelB.toObject(), 'name', 'original', 'type', 'slug')
     paredLevelB.campaignIndex = 1
     campaignJSON.levels[@levelB.get('original').toString()] = paredLevelB
@@ -124,7 +131,7 @@ describe 'POST /db/classroom', ->
     [res, body] = yield request.postAsync {uri: classroomsURL, json: data }
     expect(res.statusCode).toBe(403)
     done()
-    
+
   it 'makes a copy of the list of all levels in all courses', utils.wrap (done) ->
     teacher = yield utils.initUser({role: 'teacher'})
     yield utils.loginUser(teacher)
@@ -136,7 +143,17 @@ describe 'POST /db/classroom', ->
     expect(classroom.get('courses')[0].levels[0].slug).toBe('level-a')
     expect(classroom.get('courses')[0].levels[0].name).toBe('Level A')
     done()
-        
+
+  it 'makes a copy of the list of all non-practice levels in all courses', utils.wrap (done) ->
+    teacher = yield utils.initUser({role: 'teacher'})
+    yield utils.loginUser(teacher)
+    data = { name: 'tmp Classroom 2' }
+    [res, body] = yield request.postAsync {uri: classroomsURL, json: data }
+    classroom = yield Classroom.findById(res.body._id)
+    # console.log(JSON.stringify(classroom.get('courses')[0], null, 2));
+    expect(classroom.get('courses')[0].levels.length).toEqual(2)
+    done()
+
 describe 'GET /db/classroom/:handle/levels', ->
 
   beforeEach utils.wrap (done) ->

From 380977f7661119316247abe75fc8c24da8ff203a Mon Sep 17 00:00:00 2001
From: Nick Winter <livelily@gmail.com>
Date: Mon, 20 Jun 2016 10:47:15 -0700
Subject: [PATCH 18/28] Fix #3744: course levels work with no hero selected

---
 app/lib/LevelLoader.coffee | 7 +++----
 app/lib/world/world.coffee | 2 +-
 app/models/Level.coffee    | 2 +-
 3 files changed, 5 insertions(+), 6 deletions(-)

diff --git a/app/lib/LevelLoader.coffee b/app/lib/LevelLoader.coffee
index cbf7bc450..b277030e5 100644
--- a/app/lib/LevelLoader.coffee
+++ b/app/lib/LevelLoader.coffee
@@ -173,10 +173,9 @@ module.exports = class LevelLoader extends CocoClass
     else if session is @opponentSession
       @consolidateFlagHistory() if @session.loaded
     if @level.get('type', true) in ['course'] # course-ladder is hard to handle because there's 2 sessions
-      heroConfig = me.get('heroConfig')
-      console.log "Course mode, loading custom hero: ", heroConfig if LOG
-      return if not heroConfig
-      url = "/db/thang.type/#{heroConfig.thangType}/version"
+      heroThangType = me.get('heroConfig')?.thangType or ThangType.heroes.captain
+      console.log "Course mode, loading custom hero: ", heroThangType if LOG
+      url = "/db/thang.type/#{heroThangType}/version"
       if heroResource = @maybeLoadURL(url, ThangType, 'thang')
         console.log "Pushing resource: ", heroResource if LOG
         @worldNecessities.push heroResource
diff --git a/app/lib/world/world.coffee b/app/lib/world/world.coffee
index 070e9b143..af7e2b65b 100644
--- a/app/lib/world/world.coffee
+++ b/app/lib/world/world.coffee
@@ -166,7 +166,7 @@ module.exports = class World
       shouldUpdateProgress = @shouldUpdateRealTimePlayback t2
       shouldDelayRealTimeSimulation = not shouldUpdateProgress and @shouldDelayRealTimeSimulation t2
     else
-      shouldUpdateProgress = t2 - t1 > PROGRESS_UPDATE_INTERVAL
+      shouldUpdateProgress = t2 - t1 > PROGRESS_UPDATE_INTERVAL# and (@frames.length - @framesSerializedSoFar >= @frameRate or t2 - t1 > 1000)
       shouldDelayRealTimeSimulation = false
     return true unless shouldUpdateProgress or shouldDelayRealTimeSimulation
     # Stop loading frames for now; continue in a moment.
diff --git a/app/models/Level.coffee b/app/models/Level.coffee
index dad6f3106..13c8a66ff 100644
--- a/app/models/Level.coffee
+++ b/app/models/Level.coffee
@@ -147,7 +147,7 @@ module.exports = class Level extends CocoModel
 
     # Load the user's chosen hero AFTER getting stats from default char
     if /Hero Placeholder/.test(levelThang.id) and @get('type', true) in ['course']
-      heroThangType = me.get('heroConfig')?.thangType
+      heroThangType = me.get('heroConfig')?.thangType or ThangType.heroes.captain
       levelThang.thangType = heroThangType if heroThangType
 
   sortSystems: (levelSystems, systemModels) ->

From b36752107e50b654be62fe668aae695fa815be0b Mon Sep 17 00:00:00 2001
From: Scott Erickson <sderickson@gmail.com>
Date: Mon, 20 Jun 2016 14:26:45 -0700
Subject: [PATCH 19/28] Hot fix for teachers playing level previews

---
 app/lib/LevelLoader.coffee | 7 ++-----
 1 file changed, 2 insertions(+), 5 deletions(-)

diff --git a/app/lib/LevelLoader.coffee b/app/lib/LevelLoader.coffee
index b277030e5..41941baf8 100644
--- a/app/lib/LevelLoader.coffee
+++ b/app/lib/LevelLoader.coffee
@@ -62,6 +62,8 @@ module.exports = class LevelLoader extends CocoClass
       @listenToOnce @level, 'sync', @onLevelLoaded
 
   onLevelLoaded: ->
+    if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course']
+      @sessionDependenciesRegistered = {}
     if (@courseID and @level.get('type', true) not in ['course', 'course-ladder']) or window.serverConfig.picoCTF
       # Because we now use original hero levels for both hero and course levels, we fake being a course level in this context.
       originalGet = @level.get
@@ -83,8 +85,6 @@ module.exports = class LevelLoader extends CocoClass
   # Session Loading
 
   loadFakeSession: ->
-    if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop']
-      @sessionDependenciesRegistered = {}
     initVals =
       level:
         original: @level.get('original')
@@ -113,9 +113,6 @@ module.exports = class LevelLoader extends CocoClass
     @loadDependenciesForSession @session
 
   loadSession: ->
-    if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course']
-      @sessionDependenciesRegistered = {}
-
     if @sessionID
       url = "/db/level.session/#{@sessionID}"
       url += "?interpret=true" if @spectateMode

From 99bae92fcbb14f9cb76bb9cde31b189d6d78f409 Mon Sep 17 00:00:00 2001
From: Scott Erickson <sderickson@gmail.com>
Date: Mon, 20 Jun 2016 14:35:52 -0700
Subject: [PATCH 20/28] Fix GET /db/level/:handle/session for sessionless
 requests

---
 server/routes/index.coffee | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/server/routes/index.coffee b/server/routes/index.coffee
index 184ba069b..4c850b8ba 100644
--- a/server/routes/index.coffee
+++ b/server/routes/index.coffee
@@ -92,7 +92,7 @@ module.exports.setup = (app) ->
   app.post('/db/user/:userID/request-verify-email', mw.users.sendVerificationEmail)
   app.post('/db/user/:userID/verify/:verificationCode', mw.users.verifyEmailAddress) # TODO: Finalize URL scheme
   
-  app.get('/db/level/:handle/session', mw.levels.upsertSession)
+  app.get('/db/level/:handle/session', mw.auth.checkHasUser(), mw.levels.upsertSession)
   
   app.get('/db/prepaid', mw.auth.checkLoggedIn(), mw.prepaids.fetchByCreator)
   app.post('/db/prepaid', mw.auth.checkHasPermission(['admin']), mw.prepaids.post)

From d829d155284b1bb5b8f8bf438c904358f8688318 Mon Sep 17 00:00:00 2001
From: Scott Erickson <sderickson@gmail.com>
Date: Mon, 20 Jun 2016 15:00:29 -0700
Subject: [PATCH 21/28] Fix(ish) race conditions in server tests

---
 spec/helpers/helper.js                  | 2 +-
 spec/server/functional/clan.spec.coffee | 8 ++++----
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/spec/helpers/helper.js b/spec/helpers/helper.js
index 095663f95..f133f469c 100644
--- a/spec/helpers/helper.js
+++ b/spec/helpers/helper.js
@@ -36,7 +36,7 @@ if (database.generateMongoConnectionString() !== dbString) {
   throw Error('Stopping server tests because db connection string was not as expected.');
 }
 
-jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 10; // for long Stripe tests
+jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 15; // for long Stripe tests
 require('../server/common'); // Make sure global testing functions are set up
 
 // Ignore Stripe/Nocking erroring
diff --git a/spec/server/functional/clan.spec.coffee b/spec/server/functional/clan.spec.coffee
index 5a1f06c11..c72aca016 100644
--- a/spec/server/functional/clan.spec.coffee
+++ b/spec/server/functional/clan.spec.coffee
@@ -403,10 +403,10 @@ describe 'Clans', ->
             loginNewUser (user2) ->
               user2.set 'stripe.free', true
               user2.save (err) ->
-              request.put {uri: "#{clanURL}/#{clan.id}/join" }, (err, res, body) ->
-                expect(err).toBeNull()
-                expect(res.statusCode).toBe(200)
-                done()
+                request.put {uri: "#{clanURL}/#{clan.id}/join" }, (err, res, body) ->
+                  expect(err).toBeNull()
+                  expect(res.statusCode).toBe(200)
+                  done()
 
     it 'Join clan when not premium 403', (done) ->
       loginNewUser (user1) ->

From 38d19a142a3ac47e686df76058d7b7fa753e22fc Mon Sep 17 00:00:00 2001
From: Scott Erickson <sderickson@gmail.com>
Date: Mon, 20 Jun 2016 16:44:07 -0700
Subject: [PATCH 22/28] Add checks to keep User purchased.gems from becoming
 NaN

Also sought to more thoroughly protect earned.gems.
---
 server/handlers/payment_handler.coffee      | 3 +++
 server/handlers/subscription_handler.coffee | 8 ++++----
 server/models/EarnedAchievement.coffee      | 2 +-
 server/models/User.coffee                   | 3 +++
 server/models/UserPollsRecord.coffee        | 1 +
 server/routes/stripe.coffee                 | 2 +-
 6 files changed, 13 insertions(+), 6 deletions(-)

diff --git a/server/handlers/payment_handler.coffee b/server/handlers/payment_handler.coffee
index 0487d0a8e..365d948d7 100644
--- a/server/handlers/payment_handler.coffee
+++ b/server/handlers/payment_handler.coffee
@@ -377,6 +377,9 @@ PaymentHandler = class PaymentHandler extends Handler
   #- Incrementing/recalculating gems
 
   incrementGemsFor: (user, gems, done) ->
+    if not gems
+      return done()
+      
     purchased = _.clone(user.get('purchased'))
     if not purchased?.gems
       purchased ?= {}
diff --git a/server/handlers/subscription_handler.coffee b/server/handlers/subscription_handler.coffee
index cf1fe8134..65760a82e 100644
--- a/server/handlers/subscription_handler.coffee
+++ b/server/handlers/subscription_handler.coffee
@@ -176,7 +176,7 @@ class SubscriptionHandler extends Handler
                 purchased = _.clone(req.user.get('purchased'))
                 purchased ?= {}
                 purchased.gems ?= 0
-                purchased.gems += parseInt(charge.metadata.gems)
+                purchased.gems += parseInt(charge.metadata.gems) if charge.metadata.gems
                 req.user.set('purchased', purchased)
 
                 req.user.save (err, user) =>
@@ -257,7 +257,7 @@ class SubscriptionHandler extends Handler
             purchased = _.clone(req.user.get('purchased'))
             purchased ?= {}
             purchased.gems ?= 0
-            purchased.gems += product.get('gems') * months
+            purchased.gems += product.get('gems') * months if product.get('gems')
             req.user.set('purchased', purchased)
 
             req.user.save (err, user) =>
@@ -440,7 +440,7 @@ class SubscriptionHandler extends Handler
         purchased = _.clone(user.get('purchased'))
         purchased ?= {}
         purchased.gems ?= 0
-        purchased.gems += product.get('gems')
+        purchased.gems += product.get('gems') if product.get('gems')
         user.set('purchased', purchased)
 
       user.save (err) =>
@@ -550,7 +550,7 @@ class SubscriptionHandler extends Handler
               purchased = _.clone(recipient.get('purchased'))
               purchased ?= {}
               purchased.gems ?= 0
-              purchased.gems += product.get('gems')
+              purchased.gems += product.get('gems') if product.get('gems')
               recipient.set('purchased', purchased)
             recipient.save (err) =>
               if err
diff --git a/server/models/EarnedAchievement.coffee b/server/models/EarnedAchievement.coffee
index 9c7cf7d46..71d7278bb 100644
--- a/server/models/EarnedAchievement.coffee
+++ b/server/models/EarnedAchievement.coffee
@@ -59,7 +59,7 @@ EarnedAchievementSchema.statics.createForAchievement = (achievement, doc, origin
         earned.achievedAmount = newAmount
         #console.log 'earnedPoints is', (expFunction(newAmount) - expFunction(originalAmount)) * pointWorth, 'was', earned.earnedPoints, earned.previouslyAchievedAmount, 'got exp function for new amount', newAmount, expFunction(newAmount), 'for original amount', originalAmount, expFunction(originalAmount), 'with point worth', pointWorth
         earnedPoints = earned.earnedPoints = (expFunction(newAmount) - expFunction(originalAmount)) * pointWorth
-        earnedGems = earned.earnedGems = (expFunction(newAmount) - expFunction(originalAmount)) * gemWorth
+        earnedGems = earned.earnedGems = (expFunction(newAmount) - expFunction(originalAmount)) * gemWorth ? 0
         earned.previouslyAchievedAmount = originalAmount
         EarnedAchievement.update {achievement: earned.achievement, user: earned.user}, earned, {upsert: true}, (err) ->
           return log.error err if err?
diff --git a/server/models/User.coffee b/server/models/User.coffee
index 56cae0eb6..b1b768fcc 100644
--- a/server/models/User.coffee
+++ b/server/models/User.coffee
@@ -9,6 +9,7 @@ AnalyticsUsersActive = require './AnalyticsUsersActive'
 Classroom = require '../models/Classroom'
 languages = require '../routes/languages'
 _ = require 'lodash'
+errors = require '../commons/errors'
 
 config = require '../../server_config'
 stripe = require('stripe')(config.stripe.secretKey)
@@ -347,6 +348,8 @@ UserSchema.methods.saveActiveUser = (event, done=null) ->
     done?()
 
 UserSchema.pre('save', (next) ->
+  if _.isNaN(@get('purchased')?.gems)
+    return next(new errors.InternalServerError('Attempting to save NaN to user')) 
   Classroom = require './Classroom'
   if @isTeacher() and not @wasTeacher
     Classroom.update({members: @_id}, {$pull: {members: @_id}}, {multi: true}).exec (err, res) ->
diff --git a/server/models/UserPollsRecord.coffee b/server/models/UserPollsRecord.coffee
index d7716ee0e..2e91d6e7a 100644
--- a/server/models/UserPollsRecord.coffee
+++ b/server/models/UserPollsRecord.coffee
@@ -47,6 +47,7 @@ updateUserProperty = (userID, userProperty, answer) ->
     return log.error err if err
 
 updateUserGems = (userID, gemDelta) ->
+  return unless gemDelta
   update = $inc: {'earned.gems': gemDelta}
   User.update {_id: mongoose.Types.ObjectId(userID)}, update, (err, result) ->
     return log.error err if err
diff --git a/server/routes/stripe.coffee b/server/routes/stripe.coffee
index 7b15882df..8ebbb2c18 100644
--- a/server/routes/stripe.coffee
+++ b/server/routes/stripe.coffee
@@ -101,7 +101,7 @@ module.exports.setup = (app) ->
               # Update purchased gems
               # TODO: is this correct for a resub?
               Payment.find({recipient: recipient._id, gems: {$exists: true}}).select('gems').exec (err, payments) ->
-                gems = _.reduce payments, ((sum, p) -> sum + p.get('gems')), 0
+                gems = _.reduce payments, ((sum, p) -> sum + (p.get('gems') or 0)), 0
                 purchased = _.clone(recipient.get('purchased'))
                 purchased ?= {}
                 purchased.gems = gems

From 2679bced07f4e79b349d22b5f019c10d86cc2f90 Mon Sep 17 00:00:00 2001
From: Nick Winter <livelily@gmail.com>
Date: Mon, 20 Jun 2016 17:10:55 -0700
Subject: [PATCH 23/28] Fix level editor not loading after recent hotfix

---
 app/lib/LevelLoader.coffee | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/app/lib/LevelLoader.coffee b/app/lib/LevelLoader.coffee
index 41941baf8..3a1bb39c3 100644
--- a/app/lib/LevelLoader.coffee
+++ b/app/lib/LevelLoader.coffee
@@ -62,7 +62,7 @@ module.exports = class LevelLoader extends CocoClass
       @listenToOnce @level, 'sync', @onLevelLoaded
 
   onLevelLoaded: ->
-    if @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course']
+    if not @sessionless and @level.get('type', true) in ['hero', 'hero-ladder', 'hero-coop', 'course']
       @sessionDependenciesRegistered = {}
     if (@courseID and @level.get('type', true) not in ['course', 'course-ladder']) or window.serverConfig.picoCTF
       # Because we now use original hero levels for both hero and course levels, we fake being a course level in this context.

From 4bac7765e2d9bcdcdbbc685c7dd94eb3fbedbaf4 Mon Sep 17 00:00:00 2001
From: Ana <esova.anaarsenovic@gmail.com>
Date: Tue, 21 Jun 2016 12:05:30 +0200
Subject: [PATCH 24/28] Update sr.coffee (#3745)

update courses section
---
 app/locale/sr.coffee | 276 +++++++++++++++++++++----------------------
 1 file changed, 138 insertions(+), 138 deletions(-)

diff --git a/app/locale/sr.coffee b/app/locale/sr.coffee
index 8491ee568..10ee69639 100644
--- a/app/locale/sr.coffee
+++ b/app/locale/sr.coffee
@@ -1084,7 +1084,7 @@ module.exports = nativeDescription: "српски", englishDescription: "Serbian
     number_total_students: "Укупан број ученика у школи/округу"
     enter_number_students: "Унеси број ученика који ти треба за овај разред."
     name_class: "Именуј свој разред"
-#    displayed_course_page: "This will be displayed on the course page for you and your students. It can be changed later."
+    displayed_course_page: "Ово ће бити приказано на страници курса за тебе и твоје ученике. Може бити измењено касније."
     buy: "Купи"
     purchasing_for: "Купујеш лиценцу за"
     creating_for: "Правиш разред за"
@@ -1095,100 +1095,100 @@ module.exports = nativeDescription: "српски", englishDescription: "Serbian
     questions: "Питања?"
     teachers_click: "Учитељи кликните овде"
     students_click: "Ученици кликните овде"
-#    courses_on_coco: "Courses on CodeCombat"
-#    designed_to: "Courses are designed to introduce computer science concepts using CodeCombat's fun and engaging environment. CodeCombat levels are organized around key topics to encourage progressive learning, over the course of 5 hours."
-#    more_in_less: "Learn more in less time"
-#    no_experience: "No coding experience necesssary"
-#    easy_monitor: "Easily monitor student progress"
-#    purchase_for_class: "Purchase a course for your entire class. It's easy to sign up your students!"
-#    see_the: "See the"
-#    more_info: "for more information."
-#    choose_course: "Choose Your Course:"
-#    enter_code: "Enter an unlock code to join an existing class"
-#    enter_code1: "Enter unlock code"
-#    enroll: "Enroll"
-#    pick_from_classes: "Pick from your current classes"
-#    enter: "Enter"
-#    or: "Or"
-#    topics: "Topics"
-#    hours_content: "Hours of content:"
-#    get_free: "Get FREE course"
-#    enroll_paid: "Enroll Students in Paid Courses"
-#    you_have1: "You have"
-#    you_have2: "unused paid enrollments"
-#    use_one: "Use 1 paid enrollment for"
-#    use_multiple: "Use paid enrollments for the following students:"
-#    already_enrolled: "already enrolled"
-#    licenses_remaining: "licenses remaining:"
-#    insufficient_enrollments: "insufficient paid enrollments"
-#    get_enrollments: "Get More Enrollments"
-#    change_language: "Change Course Language"
-#    keep_using: "Keep Using"
-#    switch_to: "Switch To"
-#    greetings: "Greetings!"
-#    back_classrooms: "Back to my classrooms"
-#    back_courses: "Back to my courses"
-#    edit_details: "Edit class details"
-#    enrolled_courses: "enrolled in paid courses:"
-#    purchase_enrollments: "Purchase Enrollments"
-#    remove_student: "remove student"
-#    assign: "Assign"
-#    to_assign: "to assign paid courses."
-#    teacher: "Teacher"
-#    complete: "Complete"
-#    none: "None"
-#    save: "Save"
-#    play_campaign_title: "Play the Campaign"
-#    play_campaign_description: "You’re ready to take the next step! Explore hundreds of challenging levels, learn advanced programming skills, and compete in multiplayer arenas!"
-#    create_account_title: "Create an Account"
+    courses_on_coco: "Курсеви на CodeCombat-у"
+    designed_to: "Курсеви су дизајнирани да представе концепте компјутерских наука користећи забавно и ангажујуће окружење CodeCombat-а. CodeCombat нивои су организовани око кључних тема како би подстакли прогресивно учење током периода од 5 сати."
+    more_in_less: "Научи више за мање времена"
+    no_experience: "Искуство у кодирању није неопходно"
+    easy_monitor: "Једноставно надгледај напредак ученика"
+    purchase_for_class: "Купи курс за свој целокупан разред. Уписивање ученика је једноставно!"
+    see_the: "Погледај"
+    more_info: "за више информација."
+    choose_course: "Изабери свој курс:"
+    enter_code: "Унеси код за откључавање да се придружиш постојећем разреду"
+    enter_code1: "Унеси код за откључавање"
+    enroll: "Упиши се"
+    pick_from_classes: "Изабери из својих тренутних разреда"
+    enter: "Унеси"
+    or: "Или"
+    topics: "Теме"
+    hours_content: "Сати садржаја:"
+    get_free: "Добиј БЕСПЛАТАН курс"
+    enroll_paid: "Упиши студенте у плаћене курсеве"
+    you_have1: "Имаш"
+    you_have2: "неискоришћених плаћених уписа"
+    use_one: "Искористи 1 плаћени упис за"
+    use_multiple: "Искористи плаћене уписе за следеће ученике:"
+    already_enrolled: "већ уписан"
+    licenses_remaining: "преостале лиценце:"
+    insufficient_enrollments: "недовољно плаћених уписа"
+    get_enrollments: "Добиј још уписа"
+    change_language: "Промени језик курса"
+    keep_using: "Настави да користиш"
+    switch_to: "Пребаци на"
+    greetings: "Поздрав!"
+    back_classrooms: "Назад на моје учионице"
+    back_courses: "Назад на моје курсеве"
+    edit_details: "Измени детаље разреда"
+    enrolled_courses: "уписани у плаћеним курсевима:"
+    purchase_enrollments: "Купи уписе"
+    remove_student: "уклони ученика"
+    assign: "Додели"
+    to_assign: "да доделиш плаћене курсеве."
+    teacher: "Учитељ"
+    complete: "Заврши"
+    none: "Нема"
+    save: "Сачувај"
+    play_campaign_title: "Играј кампању"
+#   play_campaign_description: "You’re ready to take the next step! Explore hundreds of challenging levels, learn advanced programming skills, and compete in multiplayer arenas!"
+    create_account_title: "Направи налог"
 #    create_account_description: "Sign up for a FREE CodeCombat account and gain access to more levels, more programming skills, and more fun!"
-#    preview_campaign_title: "Preview Campaign"
+    preview_campaign_title: "Приказ кампање"
 #    preview_campaign_description: "Take a sneak peek at all that CodeCombat has to offer before signing up for your FREE account."
-#    arena: "Arena"
-#    arena_soon_title: "Arena Coming Soon"
+    arena: "Арена"
+    arena_soon_title: "Арена стиже ускоро"
 #    arena_soon_description: "We are working on a multiplayer arena for classrooms at the end of"
-#    not_enrolled1: "Not enrolled"
-#    not_enrolled2: "Ask your teacher to enroll you in the next course."
-#    next_course: "Next Course"
-#    coming_soon1: "Coming soon"
+    not_enrolled1: "Ниси уписан"
+    not_enrolled2: "Питај свог учитеља да те упише на следећи курс."
+    next_course: "Следећи курс"
+    coming_soon1: "Ускоро"
 #    coming_soon2: "We are hard at work making more courses for you!"
-#    available_levels: "Available Levels"
-#    welcome_to_courses: "Adventurers, welcome to Courses!"
-#    ready_to_play: "Ready to play?"
-#    start_new_game: "Start New Game"
-#    play_now_learn_header: "Play now to learn"
-#    play_now_learn_1: "basic syntax to control your character"
-#    play_now_learn_2: "while loops to solve pesky puzzles"
-#    play_now_learn_3: "strings & variables to customize actions"
-#    play_now_learn_4: "how to defeat an ogre (important life skills!)"
-#    welcome_to_page: "Welcome to your Courses page!"
-#    completed_hoc: "Amazing! You've completed the Hour of Code course!"
-#    ready_for_more_header: "Ready for more? Play the campaign mode!"
-#    ready_for_more_1: "Use gems to unlock new items!"
-#    ready_for_more_2: "Play through brand new worlds and challenges"
-#    ready_for_more_3: "Learn even more programming!"
-#    saved_games: "Saved Games"
-#    hoc: "Hour of Code"
-#    my_classes: "My Classes"
-#    class_added: "Class successfully added!"
-#    view_class: "view class"
-#    view_levels: "view levels"
-#    join_class: "Join A Class"
-#    ask_teacher_for_code: "Ask your teacher if you have a CodeCombat class code! If so, enter it below:"
-#    enter_c_code: "<Enter Class Code>"
-#    join: "Join"
-#    joining: "Joining class"
-#    course_complete: "Course Complete"
-#    play_arena: "Play Arena"
-#    start: "Start"
-#    last_level: "Last Level"
-#    welcome_to_hoc: "Adventurers, welcome to our Hour of Code!"
-#    logged_in_as: "Logged in as:"
-#    not_you: "Not you?"
-#    welcome_back: "Hi adventurer, welcome back!"
-#    continue_playing: "Continue Playing"
-#    more_options: "More options:"
-#    option1_header: "Option 1: Invite students via email"
+    available_levels: "Доступни нивои"
+    welcome_to_courses: "Авантуристи, добродошли у курсеве!"
+    ready_to_play: "Спреман да играш?"
+    start_new_game: "Почни нову игру"
+    play_now_learn_header: "Играј сада да научиш"
+    play_now_learn_1: "основну синтаксу да контролишеш свог лика"
+    play_now_learn_2: "while петље да решиш заморне слагалице"
+    play_now_learn_3: "стрингове & променљиве да подесиш акције"
+    play_now_learn_4: "како да победиш огра (важне животне вештине!)"
+    welcome_to_page: "Добродошао на твоју Курсеви страницу!"
+    completed_hoc: "Невероватно! Завршио си курс Сат Кодирања!"
+    ready_for_more_header: "Спреман за још? Играј кампања верзију!"
+    ready_for_more_1: "Користи драгуље да откључаш нове предмете!"
+    ready_for_more_2: "Играј кроз потпуно нове светове и изазове"
+    ready_for_more_3: "Научи још више програмирања!"
+    saved_games: "Сачуване игре"
+    hoc: "Сат Кодирања"
+    my_classes: "Моји разреди"
+    class_added: "Разред успешно додат!"
+    view_class: "види разред"
+    view_levels: "види нивое"
+    join_class: "Придружи се разреду"
+    ask_teacher_for_code: "Питај свог учитеља да ли имаш CodeCombat код за разред! Ако да, унеси га испод:"
+    enter_c_code: "<Упиши код за разред>"
+    join: "Придружи се"
+    joining: "Придруживање разреду"
+    course_complete: "Курс завршен"
+    play_arena: "Играј Арену"
+    start: "Почни"
+    last_level: "Последњи ниво"
+    welcome_to_hoc: "Авантуристи, добродошли на наш Сат Кодирања!"
+    logged_in_as: "Уписан као:"
+    not_you: "Није ти?"
+    welcome_back: "Здраво авантуристо, добродошао назад!"
+    continue_playing: "Настави да играш"
+    more_options: "Још опција:"
+    option1_header: "Опција 1: Позови ученике преко мејла"
 #    option1_body: "Students will automatically be sent an invitation to join this class, and will need to create an account with a username and password."
 #    option2_header: "Option 2: Send URL to your students"
 #    option2_body: "Students will be asked to enter an email address, username and password to create an account."
@@ -1202,96 +1202,96 @@ module.exports = nativeDescription: "српски", englishDescription: "Serbian
 #    total_all_classes: "Total Across All Classes"
 #    how_many_enrollments: "How many additional paid enrollments do you need?"
 #    each_student_access: "Each student in a class will get access to Courses 2-4 once they are enrolled in paid courses. You may assign each course to each student individually."
-#    purchase_now: "Purchase Now"
+    purchase_now: "Купи сад"
 #    enrollments: "enrollments"
-#    remove_student1: "Remove Student"
+    remove_student1: "Уклони ученика"
 #    are_you_sure: "Are you sure you want to remove this student from this class?"
 #    remove_description1: "Student will lose access to this classroom and assigned classes. Progress and gameplay is NOT lost, and the student can be added back to the classroom at any time."
 #    remove_description2: "The activated paid license will not be returned."
-#    keep_student: "Keep Student"
-#    removing_user: "Removing user"
+    keep_student: "Задржи ученика"
+    removing_user: "Уклањање корисника"
 #    to_join_ask: "To join a class, ask your teacher for an unlock code."
-#    join_this_class: "Join Class"
+    join_this_class: "Придружи се разреду"
 #    enter_here: "<enter unlock code here>"
 #    successfully_joined: "Successfully joined"
 #    click_to_start: "Click here to start taking"
-#    my_courses: "My Courses"
-#    classroom: "Classroom"
+    my_courses: "Моји курсеви"
+    classroom: "Учионица"
 #    use_school_email: "use your school email if you have one"
 #    unique_name: "a unique name no one has chosen"
 #    pick_something: "pick something you can remember"
-#    class_code: "Class Code"
+    class_code: "Код разреда"
 #    optional_ask: "optional - ask your teacher to give you one!"
 #    optional_school: "optional - what school do you go to?"
-#    start_playing: "Start Playing"
-#    skip_this: "Skip this, I'll create an account later!"
-#    welcome: "Welcome"
+    start_playing: "Почни да играш"
+    skip_this: "Прескочи ово, направићу налог касније!"
+    welcome: "Добродошао"
 #    getting_started: "Getting Started with Courses"
 #    download_getting_started: "Download Getting Started Guide [PDF]"
 #    getting_started_1: "Create a new class by clicking the green 'Create New Class' button below."
 #    getting_started_2: "Once you've created a class, click the blue 'Add Students' button."
 #    getting_started_3: "You'll see student's progress below as they sign up and join your class."
-#    additional_resources: "Additional Resources"
-#    additional_resources_1_pref: "Download/print our"
-#    additional_resources_1_mid: "Course 1"
-#    additional_resources_1_mid2: "and"
-#    additional_resources_1_mid3: "Course 2"
+    additional_resources: "Додатни ресурси"
+    additional_resources_1_pref: "Преузми/одштампај наш"
+    additional_resources_1_mid: "Курс 1"
+    additional_resources_1_mid2: "и"
+    additional_resources_1_mid3: "Курс 2"
 #    additional_resources_1_suff: "teacher's guides with solutions for each level."
 #    additional_resources_2_pref: "Complete our"
 #    additional_resources_2_suff: "to get two free enrollments for the rest of our paid courses."
-#    additional_resources_3_pref: "Visit our"
-#    additional_resources_3_mid: "Teacher Forums"
+    additional_resources_3_pref: "Посети наше"
+    additional_resources_3_mid: "форуме за учитеље"
 #    additional_resources_3_suff: "to connect to fellow educators who are using CodeCombat."
 #    additional_resources_4_pref: "Check out our"
 #    additional_resources_4_mid: "Schools Page"
 #    additional_resources_4_suff: "to learn more about CodeCombat's classroom offerings."
 #    educator_wiki_pref: "Or check out our new"
-#    educator_wiki_mid: "educator wiki"
+    educator_wiki_mid: "едукатор wiki"
 #    educator_wiki_suff: "to browse the guide online."
-#    your_classes: "Your Classes"
-#    no_classes: "No classes yet!"
-#    create_new_class1: "create new class"
-#    available_courses: "Available Courses"
-#    unused_enrollments: "Unused enrollments available:"
+    your_classes: "Твоји разреди"
+    no_classes: "Још увек нема разреда!"
+    create_new_class1: "направи нови разред"
+    available_courses: "Доступни курсеви"
+    unused_enrollments: "Доступни неискоришћени уписи:"
 #    students_access: "All students get access to Introduction to Computer Science for free. One enrollment per student is required to assign them to paid CodeCombat courses. A single student does not need multiple enrollments to access all paid courses."
 #    active_courses: "active courses"
-#    no_students: "No students yet!"
-#    add_students1: "add students"
-#    view_edit: "view/edit"
-#    students_enrolled: "students enrolled"
-#    students_assigned: "students assigned"
-#    length: "Length:"
-#    title: "Courses" # Flat style redesign
+    no_students: "Још увек нема ученика!"
+    add_students1: "додај ученике"
+    view_edit: "види/измени"
+    students_enrolled: "ученика уписано"
+    students_assigned: "ученика додељено"
+    length: "Дужина:"
+    title: "Курсеви" # Flat style redesign
 #    subtitle: "Review course guidelines, solutions, and levels"
 #    changelog: "View latest changes to course levels."
-#    select_language: "Select language"
-#    select_level: "Select level"
-#    play_level: "Play Level"
-#    concepts_covered: "Concepts covered"
-#    print_guide: "Print Guide (PDF)"
-#    view_guide_online: "View Guide Online (PDF)"
+    select_language: "Изабери језик"
+    select_level: "Изабери ниво"
+    play_level: "Играј ниво"
+#    concepts_covered: "Покривени концепти"
+    print_guide: "Одштампај водич (PDF)"
+    view_guide_online: "Види водич онлајн (PDF)"
 #    last_updated: "Last updated:"
 #    grants_lifetime_access: "Grants access to all Courses."
 #    enrollment_credits_available: "Enrollment Credits Available:"
 #    description: "Description" # ClassroomSettingsModal
-#    language_select: "Select a language"
+    language_select: "Изабери језик"
 #    language_cannot_change: "Language cannot be changed once students join a class."
-#    learn_p: "Learn Python"
-#    learn_j: "Learn JavaScript"
+    learn_p: "Научи Python"
+    learn_j: "Научи JavaScript"
 #    avg_student_exp_label: "Average Student Programming Experience"
 #    avg_student_exp_desc: "This will help us understand how to pace courses better."
-#    avg_student_exp_select: "Select the best option"
+    avg_student_exp_select: "Изабери најбољу опцију"
 #    avg_student_exp_none: "No Experience - little to no experience"
 #    avg_student_exp_beginner: "Beginner - some exposure or block-based"
 #    avg_student_exp_intermediate: "Intermediate - some experience with typed code"
 #    avg_student_exp_advanced: "Advanced - extensive experience with typed code"
 #    avg_student_exp_varied: "Varied Levels of Experience"
-#    student_age_range_label: "Student Age Range"
-#    student_age_range_younger: "Younger than 6"
-#    student_age_range_older: "Older than 18"
-#    student_age_range_to: "to"
-#    create_class: "Create Class"
-#    class_name: "Class Name"
+    student_age_range_label: "Опсег старости ученика"
+    student_age_range_younger: "Млађи од 6"
+    student_age_range_older: "Старији од 18"
+    student_age_range_to: "до"
+    create_class: "Направи разред"
+    class_name: "Име разреда"
 #    teacher_account_restricted: "Your account is a teacher account, and so cannot access student content."
 
 #  teacher:

From df90935aba1956f7151dceccba97359078c504a0 Mon Sep 17 00:00:00 2001
From: Matt Lott <mattlott@live.com>
Date: Tue, 21 Jun 2016 09:29:41 -0700
Subject: [PATCH 25/28] Admin school counts page

---
 app/core/Router.coffee                  |   1 +
 app/templates/admin.jade                |   2 +
 app/templates/admin/school-counts.jade  |  49 ++++++++
 app/views/admin/SchoolCountsView.coffee | 144 ++++++++++++++++++++++++
 server/middleware/classrooms.coffee     |   5 +
 server/middleware/trial-requests.coffee |   5 +
 server/middleware/users.coffee          |  11 ++
 server/routes/index.coffee              |   5 +-
 8 files changed, 221 insertions(+), 1 deletion(-)
 create mode 100644 app/templates/admin/school-counts.jade
 create mode 100644 app/views/admin/SchoolCountsView.coffee

diff --git a/app/core/Router.coffee b/app/core/Router.coffee
index 7030d7df6..bd7641f25 100644
--- a/app/core/Router.coffee
+++ b/app/core/Router.coffee
@@ -33,6 +33,7 @@ module.exports = class CocoRouter extends Backbone.Router
     'admin/design-elements': go('admin/DesignElementsView')
     'admin/files': go('admin/FilesView')
     'admin/analytics': go('admin/AnalyticsView')
+    'admin/school-counts': go('admin/SchoolCountsView')
     'admin/analytics/subscriptions': go('admin/AnalyticsSubscriptionsView')
     'admin/level-sessions': go('admin/LevelSessionsView')
     'admin/users': go('admin/UsersView')
diff --git a/app/templates/admin.jade b/app/templates/admin.jade
index c413fcbef..f154dd99a 100644
--- a/app/templates/admin.jade
+++ b/app/templates/admin.jade
@@ -44,6 +44,8 @@ block content
     ul
       li
         a(href="/admin/analytics") Dashboard
+      li
+        a(href="/admin/school-counts") School Counts
       li
         a(href="/admin/analytics/subscriptions") Subscriptions
       li
diff --git a/app/templates/admin/school-counts.jade b/app/templates/admin/school-counts.jade
new file mode 100644
index 000000000..556330d84
--- /dev/null
+++ b/app/templates/admin/school-counts.jade
@@ -0,0 +1,49 @@
+extends /templates/base
+
+//- DO NOT TRANSLATE
+
+block content
+
+  if !me.isAdmin()
+    div You must be logged in as an admin to view this page.
+
+  else
+    p CodeCombat is now in #{view.totalSchools} schools with #{view.totalStudents} students [and #{view.totalTeachers} teachers] [in #{view.totalStates} states]
+    p Students not attached to NCES data: #{view.untriagedStudents}
+    .small Teacher: owns a classroom or has a teacher role
+    .small Student: member of a classroom or has schoolName set
+    .small States, Districts, Schools are from NCES
+
+    h2 State Counts
+    if view.stateCounts
+      table.table.table-striped.table-condensed
+        tr
+          th State
+          th Districts
+          th Schools
+          th Teachers
+          th Students
+        each stateCount in view.stateCounts
+          tr
+            td= stateCount.state
+            td= stateCount.districts
+            td= stateCount.schools
+            td= stateCount.teachers
+            td= stateCount.students
+
+    h2 District Counts by State
+    if view.districtCounts
+      table.table.table-striped.table-condensed
+        tr
+          th State
+          th District
+          th Schools
+          th Teachers
+          th Students
+        each districtCount in view.districtCounts
+          tr
+            td= districtCount.state
+            td= districtCount.district
+            td= districtCount.schools
+            td= districtCount.teachers
+            td= districtCount.students
diff --git a/app/views/admin/SchoolCountsView.coffee b/app/views/admin/SchoolCountsView.coffee
new file mode 100644
index 000000000..0ff795921
--- /dev/null
+++ b/app/views/admin/SchoolCountsView.coffee
@@ -0,0 +1,144 @@
+RootView = require 'views/core/RootView'
+CocoCollection = require 'collections/CocoCollection'
+Classroom = require 'models/Classroom'
+TrialRequest = require 'models/TrialRequest'
+User = require 'models/User'
+
+# TODO: trim orphaned students: course instances != Single Player, hourOfCode != true
+# TODO: match anonymous trial requests with real users via email
+
+module.exports = class SchoolCountsView extends RootView
+  id: 'admin-school-counts-view'
+  template: require 'templates/admin/school-counts'
+
+  initialize: ->
+    return super() unless me.isAdmin()
+    @classrooms = new CocoCollection([], { url: "/db/classroom/-/users", model: Classroom })
+    @supermodel.loadCollection(@classrooms, 'classrooms', {cache: false})
+    @students = new CocoCollection([], { url: "/db/user/-/students", model: User })
+    @supermodel.loadCollection(@students, 'students', {cache: false})
+    @teachers = new CocoCollection([], { url: "/db/user/-/teachers", model: User })
+    @supermodel.loadCollection(@teachers, 'teachers', {cache: false})
+    @trialRequests = new CocoCollection([], { url: "/db/trial.request/-/users", model: TrialRequest })
+    @supermodel.loadCollection(@trialRequests, 'trial-requests', {cache: false})
+    super()
+
+  onLoaded: ->
+    return super() unless me.isAdmin()
+
+    console.log(new Date().toISOString(), 'onLoaded')
+
+    teacherMap = {} # Used to make sure teachers and students only counted once
+    studentMap = {} # Used to make sure teachers and students only counted once
+    teacherStudentMap = {} # Used to link students to their teacher locations
+    orphanedSchoolStudentMap = {} # Used to link student schoolName to teacher Nces data
+    countryStateDistrictSchoolCountsMap = {} # Data graph
+
+    console.log(new Date().toISOString(), 'Processing classrooms...')
+    for classroom in @classrooms.models
+      teacherID = classroom.get('ownerID')
+      teacherMap[teacherID] ?= {}
+      teacherMap[teacherID] = true
+      teacherStudentMap[teacherID] ?= {}
+      for studentID in classroom.get('members')
+        studentMap[studentID] = true
+        teacherStudentMap[teacherID][studentID] = true
+
+    console.log(new Date().toISOString(), 'Processing teachers...')
+    for teacher in @teachers.models
+      teacherMap[teacher.id] ?= {}
+      delete studentMap[teacher.id]
+
+    console.log(new Date().toISOString(), 'Processing students...')
+    for student in @students.models when not teacherMap[student.id]
+      schoolName = student.get('schoolName')
+      studentMap[student.id] = true
+      orphanedSchoolStudentMap[schoolName] ?= {}
+      orphanedSchoolStudentMap[schoolName][student.id] = true
+
+    console.log(new Date().toISOString(), 'Processing trial requests...')
+    # TODO: this step is crazy slow
+    orphanSchoolsMatched = 0
+    orphanStudentsMatched = 0
+    for trialRequest in @trialRequests.models
+      teacherID = trialRequest.get('applicant')
+      unless teacherMap[teacherID]
+        # console.log("Skipping non-teacher #{teacherID} trial request #{trialRequest.id}")
+        continue 
+      props = trialRequest.get('properties')
+      if props.nces_id and props.country and props.state
+        country = props.country
+        state = props.state
+        district = props.nces_district
+        school = props.nces_name
+        countryStateDistrictSchoolCountsMap[country] ?= {}
+        countryStateDistrictSchoolCountsMap[country][state] ?= {}
+        countryStateDistrictSchoolCountsMap[country][state][district] ?= {}
+        countryStateDistrictSchoolCountsMap[country][state][district][school] ?= {students: {}, teachers: {}}
+        countryStateDistrictSchoolCountsMap[country][state][district][school].teachers[teacherID] = true
+        for studentID, val of teacherStudentMap[teacherID]
+          countryStateDistrictSchoolCountsMap[country][state][district][school].students[studentID] = true
+        for orphanSchool, students of orphanedSchoolStudentMap
+          if school is orphanSchool or school.replace(/unified|elementary|high|district|#\d+|isd|unified district|school district/ig, '').trim() is orphanSchool.trim()
+            orphanSchoolsMatched++
+            for studentID, val of students
+              orphanStudentsMatched++
+              countryStateDistrictSchoolCountsMap[country][state][district][school].students[studentID] = true
+            delete orphanedSchoolStudentMap[school]
+    console.log(new Date().toISOString(), "#{orphanSchoolsMatched} orphanSchoolsMatched #{orphanStudentsMatched} orphanStudentsMatched")
+
+    console.log(new Date().toISOString(), 'Building graph...')
+    @totalSchools = 0
+    @totalStudents = 0
+    @totalTeachers = 0
+    @totalStates = 0
+    @stateCounts = []
+    stateCountsMap = {}
+    @districtCounts = []
+    for country, stateDistrictSchoolCountsMap of countryStateDistrictSchoolCountsMap
+      continue unless /usa/ig.test(country)
+      for state, districtSchoolCountsMap of stateDistrictSchoolCountsMap
+        @totalStates++
+        stateData = {state: state, districts: 0, schools: 0, students: 0, teachers: 0}
+        for district, schoolCountsMap of districtSchoolCountsMap
+          stateData.districts++
+          districtData = {state: state, district: district, schools: 0, students: 0, teachers: 0}
+          for school, counts of schoolCountsMap
+            studentCount = Object.keys(counts.students).length
+            teacherCount = Object.keys(counts.teachers).length
+            @totalSchools++
+            @totalStudents += studentCount
+            @totalTeachers += teacherCount
+            stateData.schools++
+            stateData.students += studentCount
+            stateData.teachers += teacherCount
+            districtData.schools++
+            districtData.students += studentCount
+            districtData.teachers += teacherCount
+          @districtCounts.push(districtData)
+        @stateCounts.push(stateData)
+        stateCountsMap[state] = stateData
+    @untriagedStudents = Object.keys(studentMap).length - @totalStudents
+
+    @stateCounts.sort (a, b) ->
+      return -1 if a.students > b.students
+      return 1 if a.students < b.students
+      return -1 if a.teachers > b.teachers
+      return 1 if a.teachers < b.teachers
+      return -1 if a.districts > b.districts
+      return 1 if a.districts < b.districts
+      b.state.localeCompare(a.state)
+    @districtCounts.sort (a, b) ->
+      if a.state isnt b.state
+        return -1 if stateCountsMap[a.state].students > stateCountsMap[b.state].students
+        return 1 if stateCountsMap[a.state].students < stateCountsMap[b.state].students
+        return -1 if stateCountsMap[a.state].teachers > stateCountsMap[b.state].teachers
+        return 1 if stateCountsMap[a.state].teachers < stateCountsMap[b.state].teachers
+        a.state.localeCompare(b.state)
+      else
+        return -1 if a.students > b.students
+        return 1 if a.students < b.students
+        return -1 if a.teachers > b.teachers
+        return 1 if a.teachers < b.teachers
+        a.district.localeCompare(b.district)
+    super()
diff --git a/server/middleware/classrooms.coffee b/server/middleware/classrooms.coffee
index 70cbe1e08..53eded9b2 100644
--- a/server/middleware/classrooms.coffee
+++ b/server/middleware/classrooms.coffee
@@ -248,3 +248,8 @@ module.exports =
       sendwithus.api.send context, _.noop
     
     res.status(200).send({})
+
+  getUsers: wrap (req, res, next) ->
+    throw new errors.Unauthorized('You must be an administrator.') unless req.user?.isAdmin()
+    classrooms = yield Classroom.find().select('ownerID members').lean()
+    res.status(200).send(classrooms)
diff --git a/server/middleware/trial-requests.coffee b/server/middleware/trial-requests.coffee
index 14b3fb1fb..ff2984c34 100644
--- a/server/middleware/trial-requests.coffee
+++ b/server/middleware/trial-requests.coffee
@@ -49,3 +49,8 @@ module.exports =
     trialRequests = yield TrialRequest.find({applicant: mongoose.Types.ObjectId(applicantID)})
     trialRequests = (tr.toObject({req: req}) for tr in trialRequests)
     res.status(200).send(trialRequests)
+
+  getUsers: wrap (req, res, next) ->
+    throw new errors.Unauthorized('You must be an administrator.') unless req.user?.isAdmin()
+    trialRequests = yield TrialRequest.find(status: {$ne: 'denied'}).select('applicant properties').lean()
+    res.status(200).send(trialRequests)
diff --git a/server/middleware/users.coffee b/server/middleware/users.coffee
index b334ca8f2..4f974f458 100644
--- a/server/middleware/users.coffee
+++ b/server/middleware/users.coffee
@@ -94,3 +94,14 @@ module.exports =
         verify_link: "http://codecombat.com/user/#{user._id}/verify/#{user.verificationCode(timestamp)}"
     sendwithus.api.send context, (err, result) ->
     res.status(200).send({})
+
+  getStudents: wrap (req, res, next) ->
+    throw new errors.Unauthorized('You must be an administrator.') unless req.user?.isAdmin()
+    students = yield User.find({$and: [{schoolName: {$exists: true}}, {schoolName: {$ne: ''}}, {anonymous: false}]}).select('schoolName').lean()
+    res.status(200).send(students)
+
+  getTeachers: wrap (req, res, next) ->
+    throw new errors.Unauthorized('You must be an administrator.') unless req.user?.isAdmin()
+    teacherRoles = ['teacher', 'technology coordinator', 'advisor', 'principal', 'superintendent', 'parent']
+    teachers = yield User.find(anonymous: false, role: {$in: teacherRoles}).select('').lean()
+    res.status(200).send(teachers)
diff --git a/server/routes/index.coffee b/server/routes/index.coffee
index 4c850b8ba..cc4ed4bee 100644
--- a/server/routes/index.coffee
+++ b/server/routes/index.coffee
@@ -69,6 +69,7 @@ module.exports.setup = (app) ->
   app.post('/db/classroom/:classroomID/members/:memberID/reset-password', mw.classrooms.setStudentPassword)
   app.post('/db/classroom/:anything/members', mw.auth.checkLoggedIn(), mw.classrooms.join)
   app.get('/db/classroom/:handle', mw.auth.checkLoggedIn()) # TODO: Finish migrating route, adding now so 401 is returned
+  app.get('/db/classroom/-/users', mw.auth.checkHasPermission(['admin']), mw.classrooms.getUsers)
 
   CodeLog = require ('../models/CodeLog')
   app.post('/db/codelogs', mw.codelogs.post)
@@ -91,8 +92,9 @@ module.exports.setup = (app) ->
   app.put('/db/user/-/remain-teacher', mw.users.remainTeacher)
   app.post('/db/user/:userID/request-verify-email', mw.users.sendVerificationEmail)
   app.post('/db/user/:userID/verify/:verificationCode', mw.users.verifyEmailAddress) # TODO: Finalize URL scheme
-  
   app.get('/db/level/:handle/session', mw.auth.checkHasUser(), mw.levels.upsertSession)
+  app.get('/db/user/-/students', mw.auth.checkHasPermission(['admin']), mw.users.getStudents)
+  app.get('/db/user/-/teachers', mw.auth.checkHasPermission(['admin']), mw.users.getTeachers)
   
   app.get('/db/prepaid', mw.auth.checkLoggedIn(), mw.prepaids.fetchByCreator)
   app.post('/db/prepaid', mw.auth.checkHasPermission(['admin']), mw.prepaids.post)
@@ -105,5 +107,6 @@ module.exports.setup = (app) ->
   app.post('/db/trial.request', mw.trialRequests.post)
   app.get('/db/trial.request/:handle', mw.auth.checkHasPermission(['admin']), mw.rest.getByHandle(TrialRequest))
   app.put('/db/trial.request/:handle', mw.auth.checkHasPermission(['admin']), mw.trialRequests.put)
+  app.get('/db/trial.request/-/users', mw.auth.checkHasPermission(['admin']), mw.trialRequests.getUsers)
 
   app.get('/healthcheck', mw.healthcheck)

From 56cdfa9fc7aeb0cb648c22f186022000537e6e10 Mon Sep 17 00:00:00 2001
From: Nick Winter <livelily@gmail.com>
Date: Tue, 21 Jun 2016 09:41:21 -0700
Subject: [PATCH 26/28] Update our /privacy address

---
 app/templates/privacy.jade | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/app/templates/privacy.jade b/app/templates/privacy.jade
index 5e878f8e4..b80c6f7bc 100644
--- a/app/templates/privacy.jade
+++ b/app/templates/privacy.jade
@@ -160,11 +160,11 @@ block content
   p
     span CodeCombat Inc.
     br
-    span 360 3rd St Suite 700 (Livefyre)
+    span 301 Howard St Suite 830
     br
-    span San Francisco, CA 94107
+    span San Francisco, CA 94105
     br
     a(href='mailto:team@codecombat.com') team@codecombat.com
 
   p
-    em Last Edited on 2016-02-01
+    em Last Edited on 2016-06-21

From 6eef19e48895e3a11373bbd85f10ee6cfa1fac6e Mon Sep 17 00:00:00 2001
From: Matt Lott <mattlott@live.com>
Date: Tue, 21 Jun 2016 10:19:47 -0700
Subject: [PATCH 27/28] Update homepage course languages image

---
 .../images/pages/home/course_languages.png    | Bin 6693 -> 3907 bytes
 1 file changed, 0 insertions(+), 0 deletions(-)
 mode change 100755 => 100644 app/assets/images/pages/home/course_languages.png

diff --git a/app/assets/images/pages/home/course_languages.png b/app/assets/images/pages/home/course_languages.png
old mode 100755
new mode 100644
index 2f3601b68e487310241d33a0cc4f34e5c5cbf1c0..7a4e6cc9262cbce84514f6bb3c5bfc837bcbff57
GIT binary patch
literal 3907
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
R?d1Rf002ovPDHLkV1na0Yx)2H

literal 6693
zcmV+=8rtQFP)<h;3K|Lk000e1NJLTq006K60027(1^@s6+rR)t00001b5ch_0Itp)
z=>Py3-$_J4RCodHT?>3v#krq3d-f3m*@TC@NhnoRs+Cu{KH95jK`{?O;-gj}0lBC@
zy|vX^ZS9R$Ep1h#iY+1}l=`?z5l9dUwiT7$iZv)zw1PkqB=Sf|HqYI&=bX9!Z?h-a
z-DG#ql4Lh<cYeR*oSFG%=9`)CeDlrseKX6QIrIO>{+|TF`HqItlI4l+?&L*_tX~}0
zFBF<Oo^zhbg%~abPi0K>W1P1#E@~L#HB9hY&hT!PKB|9xb!DRQjz{~>oP}fWojj_t
zsHjLc)1E8@yWR}o837|$b0YhZcHM$+aaF(ni?5VxG43gZi@=NUA=UOD2}tziP;T3+
zqA_Pq!EO-tPh6#I-t>W=q5nA3#ynkKH5J`^%}f_1GeAk0?vFrvZoxdJ>3;`F&NYZP
zmRdpRlZ4K;IkOAzkEPt{2FqosRxte<vv?;7*z8Dk11=+fkrTwX6x_9Kyg7f7OVWQp
ze2A5sa>4Z_X*u(WL!FU9sB9u<EZuD9k_2paq`Cv=(Jlk$9q6lq^Q{eKCAYdti&svZ
zoH+}!?*5pn7R&<pJ7X$y8UYKY<uJ}nk4ch%<1o3pE=C75(>^&nii%Xu#i4*aQ?KAq
zlS~1gW<cq=g;zklnkzB+MYE_U3E1pNbpwtwy<bOhaZ5(-!V4*$kyCgLv=7re@=U%7
zHpnF8X#+;u2ce6#Sdq5Ym9F}yS;S?tfJwL>8UYZWCe%I7?D-9v(9?RT*3-J0!Lx5a
z$Lm$M3(g!CX1ybp&LlZ-ETh$fu+Wqxi5KGuu_4#1s_#SY9b)0qb#-Mcs{)7FdEKIM
zXXP#&8kiRNp5(xRqtQ$6f=i!3j94ADdcj9wvo|v;+!+7>Lxr?#cjY6xuKyI;m<CA_
zSJ!P?{Wt2`h@3_Jnwj@|c%3E-@2caaB?rQFIZ43bMy3bTU1i1FQ62q73~6}<GnvND
zcmf`rTR328dBrQR(p-xH*=OZEw`Nnx=lS`sS+;xrw4hm6el8eaF*D_w#p~AHphX(A
zBmqYnofz_nYqSVuG32;BhzDn*=vmm1Kw%I=Fl9XLs1Me;m(kXZ?I}Vi;AWq~V)vI+
zVJoUGTiMc{*GFD3PJ^3;@)r>Ag~0W%Y=C2K&C9>58#;UOS=-!OHet-h$QGV|a#P9w
zu45%pu};dsQHLlx00?PP6Z0Mf{XGRW(04F7!|`YF87|0p5zAln<^myt>t@X1x7gj^
z#FX;1`~_{g=fDyPe9xL+<iI$UVvIn%?4683nb*2Nyd>wkRrf9;Ub{_PPQ+^#%_ISv
z9jP#ISPsX73vL6EhTUj{DcZZqf}P9U+OpnlB(~b?PFCKFak?1GQZu(&ZmU>R<fa;g
z=6wl7oh5O8$BFf8+V$jSYEKfdnNf-W;}zn+F)4#1fFv$Uuv<yLnm7c4TZTD3n^J@%
zJ#To|IdcoAVEm>*+R}e8Y0T!}647Hx0**dLVIc<2SvU%uV@_CrUdn)+-Pl&Out#lz
zYd1xOi4y=guTLiDf<as`g5LIlLCkvY<BD-cs@Z3ffX$9n7jT+NUQ;6O8(|Q_zuM5@
zW4b^qI7zVY1}7viCb8Fjtqay6n+x8qB2yIpXsp;J2{_gf3xpS9N?<g8@BED+`F9S3
z?0r$xHiejsjU8w{@nMubb2SVWd*M;%6D0^ES6Rt!Aomfy1J4`bX-E2B(;-Q~riZFa
zz=>U^_F^JU_6)~0B_4O{FEQg$=MO}lHY9JsIlYLI>8kgxD^g@;y^%A1*}%MlON@dR
zF5dwe*z1o!`SrW+jxHMcfujz9(Ma;y#R!nPEUtl<vb2=x+dW&RgLvneZQ9H9=%R+%
z%DM@6vh0Pu(ErZXH1XW1{CiWVx|3zA-ow$UhkaFJOl!VuKG$qBahKoBB6d2!!R#Be
z_5u3ODsaWuIhQ_Tx^S84C)7&~c8B=j=(>^<Y1xH$L0|e57F;t$eK3$9V#d>S<);8x
zUt0WDM&A5;8r^k2NAU-#iW!bsPi|}7`Xh`_DwpL7N;4CZ9N5e#g@FMlf$hR%z!C}g
z$1?_Hji2e5b)PKcceuoloB5*_T0HE=wCsX+7}J-7cug;{R2EBifIBCpBtFz_TJtWq
zSYIV024O=cWB5il;DGDHb?a7k`C(7UFS0@JJ|YnNBnJ*0jo9C7Q6xHXfY32H*E%eY
zJ$1E}%eILMTK*j|Fws~TZl_*wiJk0}7>5Kh8#i(7DiCEBM1;>|tM&bw*H?aJG+n>(
zg^y^1$@xl_k-Okhjp@s<slx}_InaUFYER`kx?t1OatdZjoIO-uTJrlO0e2oN(<xZQ
zs{uSZS^;dlmN3b7uvPW!h3p#l<sA0rW6^Nz$$WCu*O|VkS$L;dgt+snlvz)NbIJkr
z`eY&4-4E53miWBU3|z?RM+kZ3bjMZuQT~0T)8C8EoWh^Li){^DF{_dUY>d_^JVBVd
zG5XUY6$MP4BYn`4*7}-V;J9%oUdRuML$~_WRFUxPsXVg$=|X!E<1!HPY>UpMtG9W!
zW~SvXsIf2@V1&>$N$3_$*mQ|G+Qb`?uHt}7dhYzeLgym{7iVGI+^Wz&m91<^DDiVE
ze?=&43FNozBmsA0=D<8YfF&++;OuRs{v<j0Oo$BFOSyvYs@@zwuQXTLszbQAW!Rx(
z-l>|-&w~bf1ddXl2iZLx%fAi7Wy9vxqBGtK@u3yH#-CJ!ajcds(jIuyRi2eKVDA^t
zKI;iLB0|;L)PjOl#E*b+V+Fppot=>qV}ShF&gsD^_?mG}ikIQ<5b9BMrVYTrjKTnY
z8<5lRWm|(w^nOf|<?Xsx)6sVL%`j(2F;V1RnUDZS@%8a;xP0YG(&>^%8U3-Lx89d}
zbnO!@hPXg!kwTX{IDY}s5ud1N`UN=jK}%X{{A6GdT@wWK%R><-c?MtSlF)P`4#M4t
zul5(NvNiuO(n6o<a~5RkUi}de(!9{T-i)7oebtw^n(Hm#cV*aigaiWD<Hsn15pj$D
zDQkn1;v2*GJkVNKdkKg)&rHjtX*jBWIZl#ohfl2gZBt+wSRebJsA${>CMI#4H2vgD
z&o(4#U<2^N!V)Gx|M|oEvXZC6WVa{#J)7pkFBdo8u-``Hi0!3%=EWlMpR2mK93lcl
zyuKB{G@c=U+k|2z7@Ud3MVOoYSZVh`L?F!#?tJ4a#*8N4j!ouZtfWJrkQjTowGyi_
z{7byre&=-yZ+2~5RZjSm<qp=z2k{}SpExP=1-g=e(&I?LKtF~cF}~ak@dj224*z_5
zPC-8i_e%rQd-I)~uW{YegP4n7p&hJjeItNCJ{gdbUKD9Ljt8a_r({jtQ(k#Bl)P7f
zFwch0;Q|orP*R`y+J}k}$A^x=C{ztq+Ya1V^gL(Md#U!c-CwSInq*{_KD+RnxUola
zm9EYuF=R(+2j+1k;EqP4zHwID-;w^~`lx`N0qRPNmqEQ!fC0Lob8hdXxHcC*fi|(B
z6aNM7KM`MQ!cH7xf)AsYTWpS$`wRl;onD)()z2w61DgI!hrnf{enasdD#&?pr}BN1
zfEj<OtHB?bv+&<EracZK84q4}0Dq4K-quL>3xJS*7f<3fMwHB>cz+9RbLR~UGmP|9
zx5eGUl*L9o(i6n@mT*XOBf&(<vw4KKrnK1aD;x<jzIk+!3yc)=T$@(kM;tU#>$FLW
zq=5)68$SZZ8WGyKFHRlmBwdJIp|AsQxjRj5Xxxli+HR#0Y$V>dr{xxYf3$tfy2Gs%
z4=P?2cJ<iItfB36H}0RHcFR`-DooFwZ?algtXlQ^q5{J=0w<k+85ORN0BAyc40D_V
z=92HsF8CCB-s9k>HJEzH!Cb=h4}m-}0t{*V@k5QHU@|YWNYbzCO52XvN9soW2sjcJ
zA$gGBj)o*Lt+Dx>cH#}lgqd>1VRz-jNE#2~4ahnDUNMnl(FdP%OC;>k=Of7P(*#T_
z5%qySXvyNdO=zyWFgx(|6*DkR$TRp>+no7lj!sp-gZ{m(OO{SF(AAjqNdm?vnHq|$
zWkBja&DC@g-R|(_2M`9TKOP|7Gc=GN1rvY91gB!oFYT+nB!W%PSvZ|>{d$mXoilI2
zr*^AOrr-Vb8<){`=5hKXRhZ&c#!H$u9DTAtD*-DquazWr@Scg8M{wauR0k<KM~B-z
zKcKOQBLPR2eFzyNXpDao?=V_)1k(Az#-4}Xy^O&c-H~%<zrksxu)cK7@2Qv2nLiFo
z>_Z?BKf~%9eb2~QGzLWV8raD+naLNEe{VR<{JLGAYAh6{Xj5oPaGd1h;}uPFz@g7Y
zPP_>?7@78%p-yQ}pk;^UBKlZBlJB*ytav9poX&0H%6vfG<J-)I0`<kXMXJH=?5Bho
zw_BmO_Xo~4fD1na`aNhQ^@XS4%2nW^zDu{U$=(j*#dgfkdoVW~VBoC%RQbs8;mqZ?
z(1k@A+*ew<_i(yh*9K{dJV=wYQLx{RWs?3ikjcmsm5oPaiT?qr%U1~Y+4Aah;>Lj?
zxSQr0;ILDjd4*TGHWmLlw734-W_}Bp-wn)NaJ4SPe?qL70XUz`6$;-M;x-{|^Kj7u
zocqO!k8o@BG5>+eaiQn74WhI&)_Qsq9xFsKF5r8^E7Wgc^~}OVj6r)-bWP0W8XE(K
zGkkwFvw*N$fj?<UG^`<ThaerWkn!%o>^YWxP-#UcPb!Q<##bu=wGXE7k0pz|xpu><
z&!`V0XWuml-2V2Z<&~Gp5__1ZWzRQFSX99&2k3Si%GL}HEl;LKkanjH355B>smwYF
z1X~kYCNxfpZe0~iz>%*B%?pn6wGEp~<^`w4CzhU9I9u2B^=K`&4-J!yrFknhw*Bz|
z{5#6;lbr*ml1LRF%ul@)G7Hl=II4ke<*8GSrC8mX!^_nai(*OPve0!^YVIdno4IUj
z2~?dDhkfS5%8|N|#xh+WhbksvGcXnVfhJgzU!7sUy4dFgpLhZ;{0Zc`Fuo9cztw8V
zGrqoPLkShljUbQbG11PhVG~Edgpc1(7OMx#>Kebusrabh;ud>h{l*m)ks<p=F5Oz>
z<>CTdTzwXB?+@`O4cu}7K1TdHmheJ>rV#f<T0i8a!nHjW1ZqV*-OrVeLp1pkbNU%>
zL-~-U*>;DT#^WYJ&;l6yu!^(2s<sCn_{;eK$D^Uhht}uXT=MF`oZCOqnEFnrwjvZZ
z5pl@yTOq?wXpxkgK*aMfAeUm|BKlBZKhTOv{1fEKHMoAX41{nB<CuuvG6ti{=V+P!
zIt7Cx>t1NitPrx`)%Udw))Gxl4y`C|A_Ts=lOV7*BSZie<c$`d0|x*?1VJ57J7oI{
z$2P2hB0PM^$ey30F&1DkH((RXfekE-r%$EiZv7Uv2N%I1a~KGfL@YNx_Bs&uVSMxN
z<A-4Wxx??cVw6ihJ4PUW1k71ITq4^|tOg47yJwK+e{IC0Ke-1J`x=Ou&QI+Q2M^i$
zHE_B7;QYQtz`?kk-m&!Sevr<!hKnHv9IY>1{b9U%6W$TK$1%WS*hRq2<zSYv1el6(
zX&q#!a)=hCq=W3Tw8Wv8Oon)kfFxY!<2OIHU^V?&yxOF8;z+>Yhg}4Gv8Rp$?CTm+
z-XqmYXw|8@bw_AgUyom2R3zi<*wuX<&%T6Tk8k3z$>>tw3G;W?w}o?N1xF1Na1#y3
zMQ}n~76yP!>cgAV*6WbJIuM4`Z@6bmLB1_8y)VCi_)&ExF3Tl*l)W$G84GTM=PVko
z=-OzAe416)_Zi-Dq+&bfsj~MUIL2$-6HN8eNBqg|suYLasTyiEvcS2j8^@@MG*q_f
z$L_iy>j2bjP%ri%2Zn%fz#>_)At%ft1vOP35)OyNeNl)4xQahGppK6uNtzCwZJy0)
zy#aMs1w#bKz{Q(`Q`%z@clhm434C$p7W{Z%cHzYdM4?xj&d4dq#y;c=MNz7ms_hc0
z`T_1zK8pU{K@A>Js;*UZz2RhS#an?0d{|!j14?gn*FQ?>ILYx6juF*)JZv9Cly~Wg
z-`Dsjxz1AlsW0DD8i6=2{y#h<Yrq7!zrBG|6mNsz9z<3MUlq^z3WWX>+=qxSne$^3
zmkREkk~P`yU(4ts42jvkjfVanT9o4WvNz1M&s-Opha)JXfkB@qVA!L+2P<VK5h%1j
zeLaqItwDPn-D3F)1hACJmTBBBjf1n!_0$IllD`?2UofPfE^h;!Kt;%=4a#3Uvb}$}
zO_SbXf%_{xQRyy7nK$8gGbUKM?K}vaD^O+vTxXx4{BDU5AYK;~>leA8SPwJ{xk^iZ
zou0ekJqYw<`lOz1hQ`2$5H)H9I<4xiul;tAbZkl6Xe<u~9Q}3}IQ6CQC%png@Lkx&
zQ&49Mmo3}BYn}0K=)D0{P7~~AqkKG`zF!#9#Vx>VC@J5q5`P%WWi`fOB2IN?H<T9p
zI11r{`wx~g$?+r;%6EjOkAY3~huDw~z>NvMgR3|THW@am@8QJQU>vVm>)O2fFI0}W
zj-sj$p^Xuk+TAvX<1yplqceNKm4a*Ypd}uSkNtD2)%r-_EjU!ZH-pbA9MFLOEL)({
z&O!9?Hc)V$$1RP=obH6*@0*y<T(8G78%38>afq5kz^X;?cPS2r7{vJC7yf|iYFrF;
z_=S|;EdmBnIfbfRi+Y};$^rvaDeoXoe_;cN_h!^FpdA3eGXcL*vZURFk0XeK`{VQx
z1bqnW*sAv8#*5$i{l`YUx1S)$Ik-N*$YPae)vkZxNJid*A1XrMjt}v|r*~IPfyF<_
z4H6Cj`CM1le0`Yw$`{q%`$3G)ojqJG-G5Yr42(U2s@-6>ixr9@T?i)d1~}9>p{ZQ)
zdUsg3C4Yo{^goWcD}Ls2x4en5x+EjF5CWn8O}nGtVw=jnO=|PYfF;%Bt-alMqy9Dy
z+*zSS*Gsu2?>V?50`8M-;vzhStyB)n|3;m$T+H+wM3?)1l%`etI@s>6n$nWSj=BXO
zl)LDBI0}svjZJMLCyi5E9R}E`J)s8s6>b{5AlA~fjG#m0BN`MiAva{?6+BD785Wdb
zGD4dGoZMIrLKtMSNVmbTB4DG*s_B?LeQ1L-Q;D0L{hRWtOTcl0KE%Mr{8lFor*b{?
z&Ls=EdV68;d4PWG1g9?6gWI0)DxPm>igu?Y*k9?C0{Iu%(+B?woPG*Emdl|R9|R)!
z9NhTmkqyt)3T`Pux$^_tiuZd){vs#o;_-uphg6;6>I88m#v*7=`f+O+`isR<e1`~K
za{GQa4n{|5n}IL+kfi4p>{pq(9`=IogHcQ~^61%Z85pyZ3Qg`#5b><QiX*+#4TG(2
z@n=kWrwtsH({*+ielzUtusYr_mD62XyUQy!qTaZ1o~}^wo#_$`&i8z5SNSof)mwB(
zew=0DhQQ-S*<*irk~XgUF<PD4z|Bp*)4T?3BA&WiS&jZ*Z;{xvhO&}eh>&;U;|>%l
z7-@>|q5Tc>Wocn*HdY%FC79K!zeG>C1x(O&@hTX^UO2Fy2WPO`E}D{gFXb7b+XPG%
zfr!)K?plf+V{s&IOh1YOxn~)6qjX1rPlhoN>GZ_u15J4M;XK)kaDurE%W+s-sa|sS
z_Ud{ikRg#Kp@CKpY*3tB(rSH(K#=V|Lc9u-{f(e{pkl-=W0gT^eI~+Zz(n3UcBmB}
zS$pU-4p=!zZHQ@uCn3kLlwz}P{M42zw9CP@@k5^cGrtJQDk_4--fqdj?P#@Ofb~xl
zXc{YkR~UH^y^rJ*AZ`x^eh3g5r+U?kkY)?Ns0P)Dh_FLOx&=4e-12mJWeYX|A0zQ!
zsLxA~s0Ww8iOH&>j-+^EgBVKYR60KMX6x45pxSFlfib0-sh43~Ceo#MbZ;2lA|w=z
z{9k<m@?ay}1V@L%9U2Nia%QvXs}&Ew-FbcC*Iet1590m{oK6unGg4;mc+<1R$GO7Q
z5s!3mPlJ|xbA#DAj=3Lq;49@RY%n)5Nt+32YHlbl!Y*xO9{nl6&c%dc!!NBhV7R0o
zhsZ$0eJc*{ehg7dhT08#hK^t~+L6)&Z)k87S{-Fn1?1X7`-_oa{)x`_12oA$(*mmr
zd^cZP&qHFlv@>i#vBJ=OZC**{GpjfFF4d!x)6X!Wz6qIdzwqi`g18k6lYD-%aLRrF
ztJhtyz|~nKeW$7gFSAe+egd~c7jY$MG-m4_{aG*w4<@_CA<0*g1_rv-DmVi{o!K7h
zF)p9B$Kvn;W-`p*jEkO7o85zct;L4*Zs>&W#>u`<us!%G@Cx$%rjHRT_s5uD?4#i4
zl4gI^KzD|H*6Z8dTh=4*di3{2eCP^T`12S)xLFFH`ZaFOJO)yUefEws4<=wCKG?a~
ze3>!>!w35-*(Oh=O<!Wiqvu-382k?i?M6xB^XcMgs+lZk^_9(8HX+xNyi3-%BWl%o
zR@rgDQ2!Ae?RA8#X2=?I`&}?8xjoQ^U-j^TnZx9RwAuCP_~BQKqeY+g3I}d)Ir-9Q
z1TfLlG0_cY3h|;Z9Qyem5%EG!{UUu}=BsoBnS%d<lF!>>jQLMz>@niTll1&}r@t-(
z{Wb_PP8dgXK)8nuz$5`1Bhj6w#Oei<l#!}%<`v8Z474Lcx7*u(ay1hC>5}LB2`?dv
zu=(04JM1?F`ZdI(4>GfGQ_EgFkm>C<aNEm~-e|+MspOCN$*4C1t&ySaz&mcsk+SF4
v8@uVy{L?i~OFj%O<7?Ng=#{aJHZK1Mb@IsiR(*cJ00000NkvXXu0mjfDX0y{


From bdfa6d435a354ac61d6949076b4813cfd4354077 Mon Sep 17 00:00:00 2001
From: Rob <rob@codecombat.com>
Date: Tue, 21 Jun 2016 11:48:28 -0700
Subject: [PATCH 28/28] Add extra options to verifier.

---
 app/styles/editor/verifier/verifier-view.sass    |  3 +++
 app/templates/editor/verifier/verifier-view.jade | 11 ++++++++++-
 app/views/editor/verifier/VerifierTest.coffee    |  4 ++--
 app/views/editor/verifier/VerifierView.coffee    | 13 +++++++++----
 4 files changed, 24 insertions(+), 7 deletions(-)

diff --git a/app/styles/editor/verifier/verifier-view.sass b/app/styles/editor/verifier/verifier-view.sass
index 4eb18abd9..ecc9c5660 100644
--- a/app/styles/editor/verifier/verifier-view.sass
+++ b/app/styles/editor/verifier/verifier-view.sass
@@ -19,3 +19,6 @@
 
   .test-failed
     color: red
+
+  .lineUnder
+    border-bottom: 1px solid #ccc
\ No newline at end of file
diff --git a/app/templates/editor/verifier/verifier-view.jade b/app/templates/editor/verifier/verifier-view.jade
index ff9c014e5..39efc524c 100644
--- a/app/templates/editor/verifier/verifier-view.jade
+++ b/app/templates/editor/verifier/verifier-view.jade
@@ -16,9 +16,18 @@ block content
         p.alert.alert-info
           | To Run: #{view.testCount - view.passed - view.problem - view.failed}
 
+    .form.form-inline
+      .row.lineUnder
+        .form-group.campaign-mix
+          input(id="careAboutFrames", type="checkbox", checked=!!view.careAboutFrames, disabled=!!view.tests) 
+          label(for="careAboutFrames") Check frame counts
+        .form-group.campaign-mix
+          label(for="cores") Threads:
+          input(id="cores", type="number", min="1", max="16", value=view.cores, disabled=!!view.tests)
+
     if view.levelsByCampaign
       .form.form-inline
-        .row
+        .row.lineUnder
           each campaignInfo, campaign in view.levelsByCampaign
             .form-group.campaign-mix
               - var campaignID = "campaign-" + campaign + "-checkbox";
diff --git a/app/views/editor/verifier/VerifierTest.coffee b/app/views/editor/verifier/VerifierTest.coffee
index 0826cad0c..8ad49720a 100644
--- a/app/views/editor/verifier/VerifierTest.coffee
+++ b/app/views/editor/verifier/VerifierTest.coffee
@@ -7,7 +7,7 @@ LevelLoader = require 'lib/LevelLoader'
 utils = require 'core/utils'
 
 module.exports = class VerifierTest extends CocoClass
-  constructor: (@levelID, @updateCallback, @supermodel, @language) ->
+  constructor: (@levelID, @updateCallback, @supermodel, @language, @options) ->
     super()
     # TODO: turn this into a Subview
     # TODO: listen to the progress report from Angel to show a simulation progress bar (maybe even out of the number of frames we actually know it'll take)
@@ -91,7 +91,7 @@ module.exports = class VerifierTest extends CocoClass
 
   isSuccessful: () ->
     return false unless @solution?
-    return false unless @frames == @solution.frameCount
+    return false unless @frames == @solution.frameCount or @options.dontCareAboutFrames
     if @goals and @solution.goals
       for k of @goals
         continue if not @solution.goals[k]
diff --git a/app/views/editor/verifier/VerifierView.coffee b/app/views/editor/verifier/VerifierView.coffee
index 80dbfb019..b03c9bb30 100644
--- a/app/views/editor/verifier/VerifierView.coffee
+++ b/app/views/editor/verifier/VerifierView.coffee
@@ -23,6 +23,10 @@ module.exports = class VerifierView extends RootView
     @problem = 0
     @testCount = 0
 
+    defaultCores = 2
+    @cores = Math.max(window.navigator.hardwareConcurrency, defaultCores)
+    @careAboutFrames = true
+
     if @levelID
       @levelIDs = [@levelID]
       @testLanguages = ['python', 'javascript', 'java', 'lua', 'coffeescript']
@@ -56,6 +60,8 @@ module.exports = class VerifierView extends RootView
   onClickGoButton: (e) ->
     @filterCampaigns()
     @levelIDs = []
+    @careAboutFrames = @$("#careAboutFrames").is(':checked')
+    @cores = @$("#cores").val()|0
     for campaign, campaignInfo of @levelsByCampaign
       if @$("#campaign-#{campaign}-checkbox").is(':checked')
         for level in campaignInfo.levels
@@ -87,8 +93,6 @@ module.exports = class VerifierView extends RootView
       @render()
 
   onTestLevelsLoaded: ->
-    defaultCores = 2
-    cores = Math.max(window.navigator.hardwareConcurrency, defaultCores)
 
     @linksQueryString = window.location.search
     #supermodel = if @levelID then @supermodel else undefined
@@ -102,7 +106,8 @@ module.exports = class VerifierView extends RootView
           @tasksList.push level: levelID, language: codeLanguage
 
     @testCount = @tasksList.length
-    chunks = _.groupBy @tasksList, (v,i) -> i%cores
+    console.log("Starting in", @cores, "cores...")
+    chunks = _.groupBy @tasksList, (v,i) => i%@cores
     supermodels = [@supermodel]
 
     _.forEach chunks, (chunk, i) =>
@@ -128,7 +133,7 @@ module.exports = class VerifierView extends RootView
                 ++@problem
 
               next()
-          , chunkSupermodel, task.language
+          , chunkSupermodel, task.language, {dontCareAboutFrames: not @careAboutFrames}
           @tests.unshift test
           @render()
         , => @render()