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> [](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). [](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) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 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 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()