From 0d5185dca5895baedd290b932e53466da4d7968e Mon Sep 17 00:00:00 2001 From: Robin Yang Date: Mon, 29 Aug 2016 11:32:40 -0700 Subject: [PATCH 01/15] Replace Educator Wiki footer link with Resource Hub --- app/locale/en.coffee | 1 + app/templates/base-flat.jade | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 1991b69fc..48ee53178 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -129,6 +129,7 @@ faqs: "FAQs" help_pref: "Need help? Email" help_suff: "and we'll get in touch!" + resource_hub: "Resource Hub" modal: cancel: "Cancel" diff --git a/app/templates/base-flat.jade b/app/templates/base-flat.jade index 737c5b376..1b77bb319 100644 --- a/app/templates/base-flat.jade +++ b/app/templates/base-flat.jade @@ -105,7 +105,7 @@ mixin accountLinks li a(href="/teachers/classes", data-i18n="nav.teachers") li - a(href="https://sites.google.com/a/codecombat.com/teacher-guides/", data-i18n="nav.educator_wiki") + a(href="/teachers/resources", data-i18n="nav.resource_hub") li a(href="/teachers/demo", data-i18n="teachers_quote.title") From 5c8b8832b389466c533c9d1e1106446a941239e7 Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Thu, 25 Aug 2016 14:30:52 -0700 Subject: [PATCH 02/15] Refactor and better test EarnedAchievement.createForAchievement --- .../earned_achievement_handler.coffee | 8 +- server/models/EarnedAchievement.coffee | 108 +++++++++--------- server/plugins/achievements.coffee | 3 +- .../server/functional/achievement.spec.coffee | 17 ++- 4 files changed, 70 insertions(+), 66 deletions(-) diff --git a/server/handlers/earned_achievement_handler.coffee b/server/handlers/earned_achievement_handler.coffee index 4a609d5a9..90c578805 100644 --- a/server/handlers/earned_achievement_handler.coffee +++ b/server/handlers/earned_achievement_handler.coffee @@ -49,7 +49,7 @@ class EarnedAchievementHandler extends Handler achievementID = req.body.achievement triggeredBy = req.body.triggeredBy collection = req.body.collection - if collection isnt 'level.sessions' + if collection isnt 'level.sessions' and not testing # TODO: remove this restriction return @sendBadInputError(res, 'Only doing level session achievements for now.') model = mongoose.modelNameByCollection(collection) @@ -71,9 +71,8 @@ class EarnedAchievementHandler extends Handler else if not trigger return @sendNotFoundError(res, 'Could not find trigger.') else if achievement.get('proportionalTo') and earned - EarnedAchievement.createForAchievement(achievement, trigger, null, earned, (earnedAchievementDoc) => + EarnedAchievement.createForAchievement(achievement, trigger, {previouslyEarnedAchievement: earned}).then (earnedAchievementDoc) => @sendCreated(res, (earnedAchievementDoc or earned)?.toObject()) - ) else if earned achievementEarned = achievement.get('rewards') actuallyEarned = earned.get('earnedRewards') @@ -92,13 +91,12 @@ class EarnedAchievementHandler extends Handler return @sendSuccess(res, earned.toObject()) ) else - EarnedAchievement.createForAchievement(achievement, trigger, null, null, (earnedAchievementDoc) => + EarnedAchievement.createForAchievement(achievement, trigger).then (earnedAchievementDoc) => if earnedAchievementDoc @sendCreated(res, earnedAchievementDoc.toObject()) else console.error "Couldn't create achievement", achievement, trigger @sendNotFoundError res, "Couldn't create achievement" - ) ) upsertNonNumericRewards: (user, achievement, done) -> diff --git a/server/models/EarnedAchievement.coffee b/server/models/EarnedAchievement.coffee index 71d7278bb..59e790e29 100644 --- a/server/models/EarnedAchievement.coffee +++ b/server/models/EarnedAchievement.coffee @@ -2,6 +2,7 @@ mongoose = require 'mongoose' jsonschema = require '../../app/schemas/models/earned_achievement' util = require '../../app/core/utils' log = require 'winston' +co = require 'co' EarnedAchievementSchema = new mongoose.Schema({ notified: @@ -16,81 +17,74 @@ EarnedAchievementSchema.pre 'save', (next) -> EarnedAchievementSchema.index({user: 1, achievement: 1}, {unique: true, name: 'earned achievement index'}) EarnedAchievementSchema.index({user: 1, changed: -1}, {name: 'latest '}) -EarnedAchievementSchema.statics.createForAchievement = (achievement, doc, originalDocObj=null, previouslyEarnedAchievement=null, done) -> - User = require './User' +EarnedAchievementSchema.statics.createForAchievement = co.wrap (achievement, doc, options={}) -> + { previouslyEarnedAchievement, originalDocObj } = options + + User = require('./User') userObjectID = doc.get(achievement.get('userField')) - userID = if _.isObject userObjectID then userObjectID.toHexString() else userObjectID # Standardize! Use strings, not ObjectId's + userID = if _.isObject userObjectID then userObjectID.toHexString() else userObjectID # Standardize! Use ObjectIds - earned = + earnedAttrs = { user: userID achievement: achievement._id.toHexString() achievementName: achievement.get 'name' earnedRewards: achievement.get 'rewards' + } pointWorth = achievement.get('worth') ? 10 gemWorth = achievement.get('rewards')?.gems ? 0 earnedPoints = 0 earnedGems = 0 + earnedDoc = null - wrapUp = (earnedAchievementDoc) -> - # Update user's experience points + isRepeatable = achievement.get('proportionalTo')? + + if isRepeatable + proportionalTo = achievement.get('proportionalTo') + docObj = doc.toObject() + newAmount = util.getByPath(docObj, proportionalTo) or 0 + + if proportionalTo is 'simulatedBy' and newAmount > 0 and not previouslyEarnedAchievement and Math.random() < 0.1 + # Because things like simulatedBy get updated with $inc and not the post-save plugin hook, + # we (infrequently) fetch the previously earned achievement so we can really update. + previouslyEarnedAchievement = yield EarnedAchievement.findOne({user: earnedAttrs.user, achievement: earnedAttrs.achievement}) + + if previouslyEarnedAchievement + originalAmount = previouslyEarnedAchievement.get('achievedAmount') or 0 + else if originalDocObj # This branch could get buggy if unchangedCopy tracking isn't working. + originalAmount = util.getByPath(originalDocObj, proportionalTo) or 0 + else + originalAmount = 0 + + if originalAmount isnt newAmount + expFunction = achievement.getExpFunction() + earnedAttrs.notified = false + earnedAttrs.achievedAmount = newAmount + earnedPoints = earnedAttrs.earnedPoints = (expFunction(newAmount) - expFunction(originalAmount)) * pointWorth + earnedGems = earnedAttrs.earnedGems = (expFunction(newAmount) - expFunction(originalAmount)) * gemWorth ? 0 + earnedAttrs.previouslyAchievedAmount = originalAmount + yield EarnedAchievement.update({achievement: earnedAttrs.achievement, user: earnedAttrs.user}, earnedAttrs, {upsert: true}) + earnedDoc = new EarnedAchievement(earnedAttrs) + + else # not alreadyAchieved + earnedAttrs.earnedPoints = pointWorth + earnedAttrs.earnedGems = gemWorth + earnedDoc = new EarnedAchievement(earnedAttrs) + yield earnedDoc.save() + earnedPoints = pointWorth + earnedGems = gemWorth + + User.saveActiveUser(userID, "achievement") + + if earnedDoc update = {$inc: {points: earnedPoints, 'earned.gems': earnedGems}} for rewardType, rewards of achievement.get('rewards') ? {} continue if rewardType is 'gems' if rewards.length update.$addToSet ?= {} update.$addToSet["earned.#{rewardType}"] = $each: rewards - User.update {_id: mongoose.Types.ObjectId(userID)}, update, {}, (err, result) -> - log.error err if err? - done?(earnedAchievementDoc) + yield User.update({_id: mongoose.Types.ObjectId(userID)}, update, {}) - isRepeatable = achievement.get('proportionalTo')? - if isRepeatable - #log.debug 'Upserting repeatable achievement called \'' + (achievement.get 'name') + '\' for ' + userID - proportionalTo = achievement.get 'proportionalTo' - docObj = doc.toObject() - newAmount = util.getByPath(docObj, proportionalTo) or 0 - updateEarnedAchievement = (originalAmount) -> - #console.log 'original amount is', originalAmount, 'and new amount is', newAmount, 'for', proportionalTo, 'with doc', docObj, 'and previously earned achievement amount', previouslyEarnedAchievement?.get('achievedAmount'), 'because we had originalDocObj', originalDocObj - - if originalAmount isnt newAmount - expFunction = achievement.getExpFunction() - earned.notified = false - 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 ? 0 - earned.previouslyAchievedAmount = originalAmount - EarnedAchievement.update {achievement: earned.achievement, user: earned.user}, earned, {upsert: true}, (err) -> - return log.error err if err? - - wrapUp(new EarnedAchievement(earned)) - else - done?() - - if proportionalTo is 'simulatedBy' and newAmount > 0 and not previouslyEarnedAchievement and Math.random() < 0.1 - # Because things like simulatedBy get updated with $inc and not the post-save plugin hook, - # we (infrequently) fetch the previously earned achievement so we can really update. - EarnedAchievement.findOne {user: earned.user, achievement: earned.achievement}, (err, previouslyEarnedAchievement) -> - log.error err if err? - updateEarnedAchievement previouslyEarnedAchievement?.get('achievedAmount') or 0 - else if previouslyEarnedAchievement - updateEarnedAchievement previouslyEarnedAchievement.get('achievedAmount') or 0 - else if originalDocObj # This branch could get buggy if unchangedCopy tracking isn't working. - updateEarnedAchievement util.getByPath(originalDocObj, proportionalTo) or 0 - else - updateEarnedAchievement 0 - - else # not alreadyAchieved - #log.debug 'Creating a new earned achievement called \'' + (achievement.get 'name') + '\' for ' + userID - earned.earnedPoints = pointWorth - earned.earnedGems = gemWorth - (new EarnedAchievement(earned)).save (err, doc) -> - return log.error err if err? - earnedPoints = pointWorth - earnedGems = gemWorth - wrapUp(doc) - - User.saveActiveUser userID, "achievement" + return earnedDoc module.exports = EarnedAchievement = mongoose.model('EarnedAchievement', EarnedAchievementSchema) diff --git a/server/plugins/achievements.coffee b/server/plugins/achievements.coffee index 16ad24f77..b130aaaee 100644 --- a/server/plugins/achievements.coffee +++ b/server/plugins/achievements.coffee @@ -42,7 +42,6 @@ AchievablePlugin = (schema, options) -> alreadyAchieved = if isNew then false else LocalMongo.matchesQuery unchangedCopy, query newlyAchieved = LocalMongo.matchesQuery(docObj, query) return unless newlyAchieved and (not alreadyAchieved or isRepeatable) - #log.info "Making an achievement: #{achievement.get('name')} #{achievement.get('_id')} for doc: #{doc.get('name')} #{doc.get('_id')}" - EarnedAchievement.createForAchievement(achievement, doc, unchangedCopy) + EarnedAchievement.createForAchievement(achievement, doc, {originalDocObj: unchangedCopy}) module.exports = AchievablePlugin diff --git a/spec/server/functional/achievement.spec.coffee b/spec/server/functional/achievement.spec.coffee index a0a97e68e..154be63f9 100644 --- a/spec/server/functional/achievement.spec.coffee +++ b/spec/server/functional/achievement.spec.coffee @@ -164,7 +164,7 @@ describe 'DELETE /db/achievement/:handle', -> describe 'POST /db/earned_achievement', -> beforeEach addAllAchievements - it 'can be used to manually create them for level achievements, which do not happen automatically', utils.wrap (done) -> + it 'manually creates earned achievements for level achievements, which do not happen automatically', utils.wrap (done) -> session = new LevelSession({ permissions: simplePermissions creator: @admin._id @@ -185,7 +185,20 @@ describe 'POST /db/earned_achievement', -> earnedAchievements = yield EarnedAchievement.find() expect(earnedAchievements.length).toBe(1) done() - + + it 'works for proportional achievements', utils.wrap (done) -> + user = yield utils.initUser() + yield utils.loginUser(user) + yield user.update({simulatedBy: 10}) + json = {achievement: @repeatable.id, triggeredBy: user.id, collection: 'users'} + [res, body] = yield request.postAsync {uri: getURL('/db/earned_achievement'), json: json} + expect(res.statusCode).toBe(201) + expect(body.earnedPoints).toBe(10) + yield user.update({simulatedBy: 30}) + [res, body] = yield request.postAsync {uri: getURL('/db/earned_achievement'), json: json} + expect(res.statusCode).toBe(201) + expect(body.earnedPoints).toBe(20) # this is kinda weird, TODO: just return total amounts + done() describe 'automatically achieving achievements', -> beforeEach addAllAchievements From f509c95a4b2241eca70227973592ddb2473bc0d6 Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Thu, 25 Aug 2016 15:24:27 -0700 Subject: [PATCH 03/15] Refactor POST /db/earned_achievement --- .../earned_achievement_handler.coffee | 68 +------------------ server/middleware/earned-achievements.coffee | 57 ++++++++++++++++ server/middleware/index.coffee | 1 + server/routes/index.coffee | 5 +- .../server/functional/achievement.spec.coffee | 40 ++++++++++- 5 files changed, 100 insertions(+), 71 deletions(-) create mode 100644 server/middleware/earned-achievements.coffee diff --git a/server/handlers/earned_achievement_handler.coffee b/server/handlers/earned_achievement_handler.coffee index 90c578805..04ae2ffaf 100644 --- a/server/handlers/earned_achievement_handler.coffee +++ b/server/handlers/earned_achievement_handler.coffee @@ -15,10 +15,9 @@ class EarnedAchievementHandler extends Handler editableProperties: ['notified'] - # Don't allow POSTs or anything yet hasAccess: (req) -> return false unless req.user - req.method in ['GET', 'POST', 'PUT'] # or req.user.isAdmin() + req.method in ['GET', 'PUT'] # or req.user.isAdmin() get: (req, res) -> return @getByAchievementIDs(req, res) if req.query.view is 'get-by-achievement-ids' @@ -45,71 +44,6 @@ class EarnedAchievementHandler extends Handler documents = (@formatEntity(req, doc) for doc in documents) @sendSuccess(res, documents) - post: (req, res) -> - achievementID = req.body.achievement - triggeredBy = req.body.triggeredBy - collection = req.body.collection - if collection isnt 'level.sessions' and not testing # TODO: remove this restriction - return @sendBadInputError(res, 'Only doing level session achievements for now.') - - model = mongoose.modelNameByCollection(collection) - - async.parallel({ - achievement: (callback) -> - Achievement.findById achievementID, (err, achievement) -> callback(err, achievement) - - trigger: (callback) -> - model.findById triggeredBy, (err, trigger) -> callback(err, trigger) - - earned: (callback) -> - q = { achievement: achievementID, user: req.user._id+'' } - EarnedAchievement.findOne q, (err, earned) -> callback(err, earned) - }, (err, { achievement, trigger, earned } ) => - return @sendDatabaseError(res, err) if err - if not achievement - return @sendNotFoundError(res, 'Could not find achievement.') - else if not trigger - return @sendNotFoundError(res, 'Could not find trigger.') - else if achievement.get('proportionalTo') and earned - EarnedAchievement.createForAchievement(achievement, trigger, {previouslyEarnedAchievement: earned}).then (earnedAchievementDoc) => - @sendCreated(res, (earnedAchievementDoc or earned)?.toObject()) - else if earned - achievementEarned = achievement.get('rewards') - actuallyEarned = earned.get('earnedRewards') - if not _.isEqual(achievementEarned, actuallyEarned) - earned.set('earnedRewards', achievementEarned) - earned.save((err) => - return @sendDatabaseError(res, err) if err - @upsertNonNumericRewards(req.user, achievement, (err) => - return @sendDatabaseError(res, err) if err - return @sendSuccess(res, earned.toObject()) - ) - ) - else - @upsertNonNumericRewards(req.user, achievement, (err) => - return @sendDatabaseError(res, err) if err - return @sendSuccess(res, earned.toObject()) - ) - else - EarnedAchievement.createForAchievement(achievement, trigger).then (earnedAchievementDoc) => - if earnedAchievementDoc - @sendCreated(res, earnedAchievementDoc.toObject()) - else - console.error "Couldn't create achievement", achievement, trigger - @sendNotFoundError res, "Couldn't create achievement" - ) - - upsertNonNumericRewards: (user, achievement, done) -> - update = {} - for rewardType, rewards of achievement.get('rewards') ? {} - continue if rewardType is 'gems' - if rewards.length - update.$addToSet ?= {} - update.$addToSet["earned.#{rewardType}"] = $each: rewards - User.update {_id: user._id}, update, {}, (err, result) -> - log.error err if err? - done?(err) - getByAchievementIDs: (req, res) -> query = { user: req.user._id+''} ids = req.query.achievementIDs diff --git a/server/middleware/earned-achievements.coffee b/server/middleware/earned-achievements.coffee new file mode 100644 index 000000000..eccd5dcbb --- /dev/null +++ b/server/middleware/earned-achievements.coffee @@ -0,0 +1,57 @@ +log = require 'winston' +mongoose = require 'mongoose' +Achievement = require './../models/Achievement' +EarnedAchievement = require './../models/EarnedAchievement' +errors = require '../commons/errors' +wrap = require 'co-express' + + +exports.post = wrap (req, res) -> + achievementID = req.body.achievement + triggeredBy = req.body.triggeredBy + collection = req.body.collection + if collection isnt 'level.sessions' and not testing # TODO: remove this restriction + throw new errors.UnprocessableEntity('Only doing level session achievements for now.') + + model = mongoose.modelNameByCollection(collection) + + [achievement, trigger, earned] = yield [ + Achievement.findById(achievementID), + model.findById(triggeredBy) + EarnedAchievement.findOne({ achievement: achievementID, user: req.user.id }) + ] + + if not achievement + throw new errors.NotFound('Could not find achievement.') + if not trigger + throw new errors.NotFound('Could not find trigger.') + + if achievement.get('proportionalTo') and earned + earnedAchievementDoc = yield EarnedAchievement.createForAchievement(achievement, trigger, {previouslyEarnedAchievement: earned}) + res.status(201).send((earnedAchievementDoc or earned)?.toObject({req})) + + else if earned + achievementEarned = achievement.get('rewards') + actuallyEarned = earned.get('earnedRewards') + if not _.isEqual(achievementEarned, actuallyEarned) + earned.set('earnedRewards', achievementEarned) + yield earned.save() + + # make sure user has all the levels and items they should have + update = {} + for rewardType, rewards of achievement.get('rewards') ? {} + continue if rewardType is 'gems' + if rewards.length + update.$addToSet ?= {} + update.$addToSet["earned.#{rewardType}"] = { $each: rewards } + yield req.user.update(update) + + return res.status(200).send(earned.toObject({req})) + + else + earnedAchievementDoc = yield EarnedAchievement.createForAchievement(achievement, trigger) + if not earnedAchievementDoc + console.error "Couldn't create achievement", achievement, trigger + throw new errors.NotFound("Couldn't create achievement") + res.status(201).send(earnedAchievementDoc.toObject({res})) + diff --git a/server/middleware/index.coffee b/server/middleware/index.coffee index b057f60d3..65b7f89b6 100644 --- a/server/middleware/index.coffee +++ b/server/middleware/index.coffee @@ -7,6 +7,7 @@ module.exports = contact: require './contact' courseInstances: require './course-instances' courses: require './courses' + earnedAchievements: require './earned-achievements' files: require './files' healthcheck: require './healthcheck' levels: require './levels' diff --git a/server/routes/index.coffee b/server/routes/index.coffee index b2ab4ba2e..ffe6704e0 100644 --- a/server/routes/index.coffee +++ b/server/routes/index.coffee @@ -96,7 +96,10 @@ module.exports.setup = (app) -> app.post('/db/course_instance/:handle/members', mw.auth.checkLoggedIn(), mw.courseInstances.addMembers) app.get('/db/course_instance/:handle/classroom', mw.auth.checkLoggedIn(), mw.courseInstances.fetchClassroom) app.get('/db/course_instance/:handle/course', mw.auth.checkLoggedIn(), mw.courseInstances.fetchCourse) - + + EarnedAchievement = require '../models/EarnedAchievement' + app.post('/db/earned_achievement', mw.earnedAchievements.post) + Level = require '../models/Level' app.post('/db/level/:handle', mw.auth.checkLoggedIn(), mw.versions.postNewVersion(Level, { hasPermissionsOrTranslations: 'artisan' })) # TODO: add /new-version to route like Article has app.get('/db/level/:handle/session', mw.auth.checkHasUser(), mw.levels.upsertSession) diff --git a/spec/server/functional/achievement.spec.coffee b/spec/server/functional/achievement.spec.coffee index 154be63f9..6a8d2232b 100644 --- a/spec/server/functional/achievement.spec.coffee +++ b/spec/server/functional/achievement.spec.coffee @@ -7,12 +7,15 @@ LevelSession = require '../../../server/models/LevelSession' User = require '../../../server/models/User' request = require '../request' EarnedAchievementHandler = require '../../../server/handlers/earned_achievement_handler' +mongoose = require 'mongoose' url = getURL('/db/achievement') # Fixtures +lockedLevelID = new mongoose.Types.ObjectId().toString() + unlockable = name: 'Dungeon Arena Started' description: 'Started playing Dungeon Arena.' @@ -22,6 +25,9 @@ unlockable = userField: 'creator' recalculable: true related: 'a' + rewards: { + levels: [lockedLevelID] + } unlockable2 = _.clone unlockable unlockable2.name = 'This one is obsolete' @@ -163,6 +169,7 @@ describe 'DELETE /db/achievement/:handle', -> describe 'POST /db/earned_achievement', -> beforeEach addAllAchievements + eaURL = getURL('/db/earned_achievement') it 'manually creates earned achievements for level achievements, which do not happen automatically', utils.wrap (done) -> session = new LevelSession({ @@ -174,7 +181,7 @@ describe 'POST /db/earned_achievement', -> earnedAchievements = yield EarnedAchievement.find() expect(earnedAchievements.length).toBe(0) json = {achievement: @unlockable.id, triggeredBy: session._id, collection: 'level.sessions'} - [res, body] = yield request.postAsync {uri: getURL('/db/earned_achievement'), json: json} + [res, body] = yield request.postAsync { url: eaURL, json } expect(res.statusCode).toBe(201) expect(body.achievement).toBe @unlockable.id expect(body.user).toBe @admin.id @@ -191,14 +198,41 @@ describe 'POST /db/earned_achievement', -> yield utils.loginUser(user) yield user.update({simulatedBy: 10}) json = {achievement: @repeatable.id, triggeredBy: user.id, collection: 'users'} - [res, body] = yield request.postAsync {uri: getURL('/db/earned_achievement'), json: json} + [res, body] = yield request.postAsync { url: eaURL, json } expect(res.statusCode).toBe(201) expect(body.earnedPoints).toBe(10) yield user.update({simulatedBy: 30}) - [res, body] = yield request.postAsync {uri: getURL('/db/earned_achievement'), json: json} + [res, body] = yield request.postAsync { url: eaURL, json } expect(res.statusCode).toBe(201) expect(body.earnedPoints).toBe(20) # this is kinda weird, TODO: just return total amounts done() + + it 'ensures the user has the rewards they earned', utils.wrap (done) -> + user = yield utils.initUser() + yield utils.loginUser(user) + + # get the User the unlockable achievement, check they got their reward + session = new LevelSession({ + permissions: simplePermissions + creator: user._id + level: original: 'dungeon-arena' + }) + yield session.save() + json = {achievement: @unlockable.id, triggeredBy: session._id, collection: 'level.sessions'} + [res, body] = yield request.postAsync { url: eaURL, json } + user = yield User.findById(user.id) + expect(user.get('earned').levels[0]).toBe(lockedLevelID) + + # mess with the user's earned levels, make sure they don't have it anymore + yield user.update({$unset: {earned:1}}) + user = yield User.findById(user.id) + expect(user.get('earned')).toBeUndefined() + + # hit the endpoint again, make sure the level was restored + [res, body] = yield request.postAsync { url: eaURL, json } + user = yield User.findById(user.id) + expect(user.get('earned').levels[0]).toBe(lockedLevelID) + done() describe 'automatically achieving achievements', -> beforeEach addAllAchievements From 139efe4cf7342f7bf2c4516b6ba785b7fa804c1d Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Fri, 26 Aug 2016 14:41:21 -0700 Subject: [PATCH 04/15] Implement POST /db/user/:handle/check-for-new-achievement, couple tweaks * Enforce being logged in for POST /db/earned_achievement * Extend timeout for race condition user tests --- server/middleware/earned-achievements.coffee | 31 +--------- server/middleware/users.coffee | 45 ++++++++++++++ server/models/Achievement.coffee | 29 +++++---- server/models/EarnedAchievement.coffee | 32 ++++++++++ server/routes/index.coffee | 5 +- .../server/functional/achievement.spec.coffee | 2 +- spec/server/functional/user.spec.coffee | 60 ++++++++++++++++++- 7 files changed, 157 insertions(+), 47 deletions(-) diff --git a/server/middleware/earned-achievements.coffee b/server/middleware/earned-achievements.coffee index eccd5dcbb..69ddd5350 100644 --- a/server/middleware/earned-achievements.coffee +++ b/server/middleware/earned-achievements.coffee @@ -26,32 +26,5 @@ exports.post = wrap (req, res) -> if not trigger throw new errors.NotFound('Could not find trigger.') - if achievement.get('proportionalTo') and earned - earnedAchievementDoc = yield EarnedAchievement.createForAchievement(achievement, trigger, {previouslyEarnedAchievement: earned}) - res.status(201).send((earnedAchievementDoc or earned)?.toObject({req})) - - else if earned - achievementEarned = achievement.get('rewards') - actuallyEarned = earned.get('earnedRewards') - if not _.isEqual(achievementEarned, actuallyEarned) - earned.set('earnedRewards', achievementEarned) - yield earned.save() - - # make sure user has all the levels and items they should have - update = {} - for rewardType, rewards of achievement.get('rewards') ? {} - continue if rewardType is 'gems' - if rewards.length - update.$addToSet ?= {} - update.$addToSet["earned.#{rewardType}"] = { $each: rewards } - yield req.user.update(update) - - return res.status(200).send(earned.toObject({req})) - - else - earnedAchievementDoc = yield EarnedAchievement.createForAchievement(achievement, trigger) - if not earnedAchievementDoc - console.error "Couldn't create achievement", achievement, trigger - throw new errors.NotFound("Couldn't create achievement") - res.status(201).send(earnedAchievementDoc.toObject({res})) - + finalEarned = yield EarnedAchievement.upsertFor(achievement, trigger, earned, req.user) + res.status(201).send(finalEarned.toObject({req})) diff --git a/server/middleware/users.coffee b/server/middleware/users.coffee index f18a457a6..2e46b2d42 100644 --- a/server/middleware/users.coffee +++ b/server/middleware/users.coffee @@ -16,7 +16,10 @@ CourseInstance = require '../models/CourseInstance' facebook = require '../lib/facebook' gplus = require '../lib/gplus' TrialRequest = require '../models/TrialRequest' +Achievement = require '../models/Achievement' +EarnedAchievement = require '../models/EarnedAchievement' log = require 'winston' +LocalMongo = require '../../app/lib/LocalMongo' module.exports = fetchByGPlusID: wrap (req, res, next) -> @@ -254,3 +257,45 @@ module.exports = yield user.update({ $unset: {role: ''}}) user.set('role', undefined) return res.status(200).send(user.toObject({req: req})) + + + checkForNewAchievement: wrap (req, res) -> + user = req.user + + lastAchievementChecked = user.get('lastAchievementChecked') or user._id + achievement = yield Achievement.findOne({ _id: { $gt: lastAchievementChecked }}).sort({_id:1}) + + if not achievement + userUpdate = { 'lastAchievementChecked': new mongoose.Types.ObjectId() } + user.update(userUpdate) + return res.send(userUpdate) + + userUpdate = { 'lastAchievementChecked': achievement._id } + + query = achievement.get('query') + collection = achievement.get('collection') + if collection is 'users' + triggers = [user] + else if collection is 'level.sessions' and query['level.original'] + triggers = yield LevelSessions.find({ + 'level.original': query['level.original'] + creator: user._id + }) + else + userUpdate = { 'lastAchievementChecked': new mongoose.Types.ObjectId() } + user.update(userUpdate) + return res.send(userUpdate) + + trigger = _.find(triggers, (trigger) -> LocalMongo.matchesQuery(trigger.toObject(), query)) + + if not trigger + user.update(userUpdate) + return res.send(userUpdate) + + earned = yield EarnedAchievement.findOne({ achievement: achievement.id, user: req.user }) + yield [ + EarnedAchievement.upsertFor(achievement, trigger, earned, req.user) + user.update(userUpdate) + ] + user = yield User.findById(user.id).select({points: 1, earned: 1}) + return res.send(_.assign({}, userUpdate, user.toObject())) diff --git a/server/models/Achievement.coffee b/server/models/Achievement.coffee index 6d4322139..f302810b9 100644 --- a/server/models/Achievement.coffee +++ b/server/models/Achievement.coffee @@ -6,6 +6,7 @@ plugins = require('../plugins/plugins') AchievablePlugin = require '../plugins/achievements' TreemaUtils = require '../../bower_components/treema/treema-utils.js' config = require '../../server_config' +co = require 'co' # `pre` and `post` are not called for update operations executed directly on the database, # including `Model.update`,`.findByIdAndUpdate`,`.findOneAndUpdate`, `.findOneAndRemove`,and `.findByIdAndRemove`.order @@ -53,27 +54,29 @@ AchievementSchema.statics.achievementCollections = {} # Reloads all achievements into memory. # TODO might want to tweak this to only load new achievements -AchievementSchema.statics.loadAchievements = (done) -> +AchievementSchema.statics.loadAchievements = co.wrap -> AchievementSchema.statics.resetAchievements() + t0 = new Date() Achievement = require('./Achievement') - query = Achievement.find({collection: {$ne: 'level.sessions'}}) - query.exec (err, docs) -> - _.each docs, (achievement) -> - collection = achievement.get 'collection' - AchievementSchema.statics.achievementCollections[collection] ?= [] - if _.find AchievementSchema.statics.achievementCollections[collection], ((a) -> a.get('_id').toHexString() is achievement.get('_id').toHexString()) - log.warn "Uh oh, we tried to add another copy of the same achievement #{achievement.get('_id')} #{achievement.get('name')} to the #{collection} achievement list..." - else - AchievementSchema.statics.achievementCollections[collection].push achievement - unless achievement.get('query') - log.error "Uh oh, there is an achievement with an empty query: #{achievement}" - done?(AchievementSchema.statics.achievementCollections) # TODO: Return with err as first parameter + achievements = yield Achievement.find({collection: {$ne: 'level.sessions'}}) + return if t0 < @lastReset # if a test has run resetAchievements during the fetch, abort + for achievement in achievements + collection = achievement.get 'collection' + AchievementSchema.statics.achievementCollections[collection] ?= [] + if _.find AchievementSchema.statics.achievementCollections[collection], ((a) -> a.get('_id').toHexString() is achievement.get('_id').toHexString()) + log.warn "Uh oh, we tried to add another copy of the same achievement #{achievement.get('_id')} #{achievement.get('name')} to the #{collection} achievement list..." + else + AchievementSchema.statics.achievementCollections[collection].push achievement + unless achievement.get('query') + log.error "Uh oh, there is an achievement with an empty query: #{achievement}" + return AchievementSchema.statics.achievementCollections AchievementSchema.statics.getLoadedAchievements = -> AchievementSchema.statics.achievementCollections AchievementSchema.statics.resetAchievements = -> delete AchievementSchema.statics.achievementCollections[collection] for collection of AchievementSchema.statics.achievementCollections + @lastReset = new Date() AchievementSchema.statics.editableProperties = [ 'name' diff --git a/server/models/EarnedAchievement.coffee b/server/models/EarnedAchievement.coffee index 59e790e29..a6f1c10fa 100644 --- a/server/models/EarnedAchievement.coffee +++ b/server/models/EarnedAchievement.coffee @@ -17,6 +17,38 @@ EarnedAchievementSchema.pre 'save', (next) -> EarnedAchievementSchema.index({user: 1, achievement: 1}, {unique: true, name: 'earned achievement index'}) EarnedAchievementSchema.index({user: 1, changed: -1}, {name: 'latest '}) + +EarnedAchievementSchema.statics.upsertFor = (achievement, trigger, earned, user) -> + + if achievement.get('proportionalTo') and earned + earnedAchievementDoc = yield @createForAchievement(achievement, trigger, {previouslyEarnedAchievement: earned}) + return earnedAchievementDoc or earned + + else if earned + achievementEarned = achievement.get('rewards') + actuallyEarned = earned.get('earnedRewards') + if not _.isEqual(achievementEarned, actuallyEarned) + earned.set('earnedRewards', achievementEarned) + yield earned.save() + + # make sure user has all the levels and items they should have + update = {} + for rewardType, rewards of achievement.get('rewards') ? {} + continue if rewardType is 'gems' + if rewards.length + update.$addToSet ?= {} + update.$addToSet["earned.#{rewardType}"] = { $each: rewards } + yield user.update(update) + return earned + + else + earned = yield @createForAchievement(achievement, trigger) + if not earned + console.error "Couldn't create achievement", achievement, trigger + throw new errors.NotFound("Couldn't create achievement") + return earned + + EarnedAchievementSchema.statics.createForAchievement = co.wrap (achievement, doc, options={}) -> { previouslyEarnedAchievement, originalDocObj } = options diff --git a/server/routes/index.coffee b/server/routes/index.coffee index ffe6704e0..cfca0edb5 100644 --- a/server/routes/index.coffee +++ b/server/routes/index.coffee @@ -98,7 +98,7 @@ module.exports.setup = (app) -> app.get('/db/course_instance/:handle/course', mw.auth.checkLoggedIn(), mw.courseInstances.fetchCourse) EarnedAchievement = require '../models/EarnedAchievement' - app.post('/db/earned_achievement', mw.earnedAchievements.post) + app.post('/db/earned_achievement', mw.auth.checkLoggedIn(), mw.earnedAchievements.post) Level = require '../models/Level' app.post('/db/level/:handle', mw.auth.checkLoggedIn(), mw.versions.postNewVersion(Level, { hasPermissionsOrTranslations: 'artisan' })) # TODO: add /new-version to route like Article has @@ -118,7 +118,8 @@ module.exports.setup = (app) -> app.post('/db/user/:handle/signup-with-password', mw.users.signupWithPassword) app.post('/db/user/:handle/destudent', mw.auth.checkHasPermission(['admin']), mw.users.destudent) app.post('/db/user/:handle/deteacher', mw.auth.checkHasPermission(['admin']), mw.users.deteacher) - + app.post('/db/user/:handle/check-for-new-achievement', mw.auth.checkLoggedIn(), mw.users.checkForNewAchievement) + app.get('/db/prepaid', mw.auth.checkLoggedIn(), mw.prepaids.fetchByCreator) app.get('/db/prepaid/-/active-schools', mw.auth.checkHasPermission(['admin']), mw.prepaids.fetchActiveSchools) app.post('/db/prepaid', mw.auth.checkHasPermission(['admin']), mw.prepaids.post) diff --git a/spec/server/functional/achievement.spec.coffee b/spec/server/functional/achievement.spec.coffee index 6a8d2232b..eecea44b4 100644 --- a/spec/server/functional/achievement.spec.coffee +++ b/spec/server/functional/achievement.spec.coffee @@ -240,7 +240,7 @@ describe 'automatically achieving achievements', -> it 'happens when an object\'s properties meet achievement goals', utils.wrap (done) -> # load achievements on server @achievements = yield Achievement.loadAchievements() - expect(@achievements.length).toBe(2) + expect(@achievements.users.length).toBe(2) loadedAchievements = Achievement.getLoadedAchievements() expect(Object.keys(loadedAchievements).length).toBe(1) diff --git a/spec/server/functional/user.spec.coffee b/spec/server/functional/user.spec.coffee index cac865d89..7d088b003 100644 --- a/spec/server/functional/user.spec.coffee +++ b/spec/server/functional/user.spec.coffee @@ -13,6 +13,10 @@ facebook = require '../../../server/lib/facebook' gplus = require '../../../server/lib/gplus' sendwithus = require '../../../server/sendwithus' Promise = require 'bluebird' +Achievement = require '../../../server/models/Achievement' +EarnedAchievement = require '../../../server/models/EarnedAchievement' +LevelSession = require '../../../server/models/LevelSession' +mongoose = require 'mongoose' describe 'POST /db/user', -> @@ -832,7 +836,7 @@ describe 'POST /db/user/:handle/signup-with-facebook', -> beforeEach utils.wrap (done) -> yield utils.clearModels([User]) - yield new Promise((resolve) -> setTimeout(resolve, 10)) + yield new Promise((resolve) -> setTimeout(resolve, 50)) done() it 'signs up the user with the facebookID and sends welcome emails', utils.wrap (done) -> @@ -921,7 +925,7 @@ describe 'POST /db/user/:handle/signup-with-gplus', -> beforeEach utils.wrap (done) -> yield utils.clearModels([User]) - yield new Promise((resolve) -> setTimeout(resolve, 10)) + yield new Promise((resolve) -> setTimeout(resolve, 50)) done() it 'signs up the user with the gplusID and sends welcome emails', utils.wrap (done) -> @@ -1032,3 +1036,55 @@ describe 'POST /db/user/:handle/deteacher', -> teacher = yield User.findById(teacher.id) expect(teacher.get('role')).toBeUndefined() done() + + +describe 'POST /db/user/:handle/check-for-new-achievements', -> + + beforeEach utils.wrap (done) -> + yield utils.clearModels [Achievement, EarnedAchievement, LevelSession, User] + Achievement.resetAchievements() + done() + + it 'finds new achievements and awards them to the user', utils.wrap (done) -> + user = yield utils.initUser({points: 100}) + yield utils.loginUser(user) + url = utils.getURL("/db/user/#{user.id}/check-for-new-achievement") + json = true + [res, body] = yield request.postAsync({ url, json }) + + earned = yield EarnedAchievement.count() + expect(earned).toBe(0) + + achievementURL = getURL('/db/achievement') + achievementJSON = { + collection: 'users' + query: {'points': {$gt: 50}} + userField: '_id' + recalculable: true + worth: 75 + rewards: { + gems: 50 + levels: [new mongoose.Types.ObjectId().toString()] + } + name: 'Dungeon Arena Started' + description: 'Started playing Dungeon Arena.' + related: 'a' + } + + admin = yield utils.initAdmin() + yield utils.loginUser(admin) + [res, body] = yield request.postAsync { uri: achievementURL, json: achievementJSON } + achievementID = body._id + expect(res.statusCode).toBe(201) + + user = yield User.findById(user.id) + expect(user.get('rewards')).toBeUndefined() + + yield utils.loginUser(user) + [res, body] = yield request.postAsync({ url, json }) + expect(body.points).toBe(175) + earned = yield EarnedAchievement.count() + expect(earned).toBe(1) + expect(body.lastAchievementChecked).toBe(achievementID) + + done() From cf9d082ffa8ded3028e7be6604433704a7af48fe Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Fri, 26 Aug 2016 16:25:19 -0700 Subject: [PATCH 05/15] Have client check for achievement updates --- app/core/application.coffee | 83 +++++++++++-------- app/models/User.coffee | 5 ++ app/schemas/models/user.coffee | 1 + server/middleware/users.coffee | 8 +- server/models/EarnedAchievement.coffee | 7 +- .../server/functional/achievement.spec.coffee | 25 ++++++ 6 files changed, 86 insertions(+), 43 deletions(-) diff --git a/app/core/application.coffee b/app/core/application.coffee index 79efcb178..7fcbf5b81 100644 --- a/app/core/application.coffee +++ b/app/core/application.coffee @@ -44,42 +44,53 @@ window.console ?= debug: -> console.debug ?= console.log # Needed for IE10 and earlier -Application = initialize: -> - Router = require('core/Router') - @isProduction = -> document.location.href.search('https?://localhost') is -1 - @isIPadApp = webkit?.messageHandlers? and navigator.userAgent?.indexOf('CodeCombat-iPad') isnt -1 - $('body').addClass 'ipad' if @isIPadApp - $('body').addClass 'picoctf' if window.serverConfig.picoCTF - if $.browser.msie and parseInt($.browser.version) is 10 - $("html").addClass("ie10") - @tracker = new Tracker() - @facebookHandler = new FacebookHandler() - @gplusHandler = new GPlusHandler() - @githubHandler = new GitHubHandler() - @moduleLoader = new ModuleLoader() - @moduleLoader.loadLanguage(me.get('preferredLanguage', true)) - $(document).bind 'keydown', preventBackspace - preload(COMMON_FILES) - CocoModel.pollAchievements() - $.i18n.init { - lng: me.get('preferredLanguage', true) - fallbackLng: 'en' - resStore: locale - useDataAttrOptions: true - #debug: true - #sendMissing: true - #sendMissingTo: 'current' - #resPostPath: '/languages/add/__lng__/__ns__' - }, (t) => - @router = new Router() - onIdleChanged = (to) => => Backbone.Mediator.publish 'application:idle-changed', idle: @userIsIdle = to - @idleTracker = new Idle - onAway: onIdleChanged true - onAwayBack: onIdleChanged false - onHidden: onIdleChanged true - onVisible: onIdleChanged false - awayTimeout: 5 * 60 * 1000 - @idleTracker.start() +Application = { + initialize: -> + Router = require('core/Router') + @isProduction = -> document.location.href.search('https?://localhost') is -1 + @isIPadApp = webkit?.messageHandlers? and navigator.userAgent?.indexOf('CodeCombat-iPad') isnt -1 + $('body').addClass 'ipad' if @isIPadApp + $('body').addClass 'picoctf' if window.serverConfig.picoCTF + if $.browser.msie and parseInt($.browser.version) is 10 + $("html").addClass("ie10") + @tracker = new Tracker() + @facebookHandler = new FacebookHandler() + @gplusHandler = new GPlusHandler() + @githubHandler = new GitHubHandler() + @moduleLoader = new ModuleLoader() + @moduleLoader.loadLanguage(me.get('preferredLanguage', true)) + $(document).bind 'keydown', preventBackspace + preload(COMMON_FILES) + CocoModel.pollAchievements() +# @checkForNewAchievement() # TODO: Enable once thoroughly tested + $.i18n.init { + lng: me.get('preferredLanguage', true) + fallbackLng: 'en' + resStore: locale + useDataAttrOptions: true + #debug: true + #sendMissing: true + #sendMissingTo: 'current' + #resPostPath: '/languages/add/__lng__/__ns__' + }, (t) => + @router = new Router() + onIdleChanged = (to) => => Backbone.Mediator.publish 'application:idle-changed', idle: @userIsIdle = to + @idleTracker = new Idle + onAway: onIdleChanged true + onAwayBack: onIdleChanged false + onHidden: onIdleChanged true + onVisible: onIdleChanged false + awayTimeout: 5 * 60 * 1000 + @idleTracker.start() + + checkForNewAchievement: -> + id = me.get('lastAchievementChecked') or me.id + lastAchievementChecked = new Date(parseInt(id.substring(0, 8), 16) * 1000) + daysSince = moment.duration(new Date() - lastAchievementChecked).asDays() + if daysSince > 1 + me.checkForNewAchievement() + setTimeout(_.bind(@checkForNewAchievement, @), moment.duration(1, 'minute').asMilliseconds()) +} module.exports = Application window.application = Application diff --git a/app/models/User.coffee b/app/models/User.coffee index e9705f3db..35f62b3ae 100644 --- a/app/models/User.coffee +++ b/app/models/User.coffee @@ -368,6 +368,11 @@ module.exports = class User extends CocoModel options.url = _.result(@, 'url') + '/deteacher' options.type = 'POST' @fetch(options) + + checkForNewAchievement: (options={}) -> + options.url = _.result(@, 'url') + '/check-for-new-achievement' + options.type = 'POST' + @fetch(options) tiersByLevel = [-1, 0, 0.05, 0.14, 0.18, 0.32, 0.41, 0.5, 0.64, 0.82, 0.91, 1.04, 1.22, 1.35, 1.48, 1.65, 1.78, 1.96, 2.1, 2.24, 2.38, 2.55, 2.69, 2.86, 3.03, 3.16, 3.29, 3.42, 3.58, 3.74, 3.89, 4.04, 4.19, 4.32, 4.47, 4.64, 4.79, 4.96, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 10, 10.5, 11, 11.5, 12, 12.5, 13, 13.5, 14, 14.5, 15 diff --git a/app/schemas/models/user.coffee b/app/schemas/models/user.coffee index 555234b98..3931d641b 100644 --- a/app/schemas/models/user.coffee +++ b/app/schemas/models/user.coffee @@ -344,6 +344,7 @@ _.extend UserSchema.properties, schoolName: {type: 'string'} role: {type: 'string', enum: ["God", "advisor", "parent", "principal", "student", "superintendent", "teacher", "technology coordinator"]} birthday: c.stringDate({title: "Birthday"}) + lastAchievementChecked: c.objectId({ name: 'Last Achievement Checked' }) c.extendBasicProperties UserSchema, 'user' diff --git a/server/middleware/users.coffee b/server/middleware/users.coffee index 2e46b2d42..8ce363e9b 100644 --- a/server/middleware/users.coffee +++ b/server/middleware/users.coffee @@ -267,7 +267,7 @@ module.exports = if not achievement userUpdate = { 'lastAchievementChecked': new mongoose.Types.ObjectId() } - user.update(userUpdate) + user.update({$set: userUpdate}).exec() return res.send(userUpdate) userUpdate = { 'lastAchievementChecked': achievement._id } @@ -283,19 +283,19 @@ module.exports = }) else userUpdate = { 'lastAchievementChecked': new mongoose.Types.ObjectId() } - user.update(userUpdate) + user.update({$set: userUpdate}).exec() return res.send(userUpdate) trigger = _.find(triggers, (trigger) -> LocalMongo.matchesQuery(trigger.toObject(), query)) if not trigger - user.update(userUpdate) + user.update({$set: userUpdate}).exec() return res.send(userUpdate) earned = yield EarnedAchievement.findOne({ achievement: achievement.id, user: req.user }) yield [ EarnedAchievement.upsertFor(achievement, trigger, earned, req.user) - user.update(userUpdate) + user.update({$set: userUpdate}) ] user = yield User.findById(user.id).select({points: 1, earned: 1}) return res.send(_.assign({}, userUpdate, user.toObject())) diff --git a/server/models/EarnedAchievement.coffee b/server/models/EarnedAchievement.coffee index a6f1c10fa..14d07577e 100644 --- a/server/models/EarnedAchievement.coffee +++ b/server/models/EarnedAchievement.coffee @@ -34,8 +34,9 @@ EarnedAchievementSchema.statics.upsertFor = (achievement, trigger, earned, user) # make sure user has all the levels and items they should have update = {} for rewardType, rewards of achievement.get('rewards') ? {} - continue if rewardType is 'gems' - if rewards.length + if rewardType is 'gems' + update.$inc = { 'earned.gems': rewards - (actuallyEarned.gems ? 0) } + else if rewards.length update.$addToSet ?= {} update.$addToSet["earned.#{rewardType}"] = { $each: rewards } yield user.update(update) @@ -54,7 +55,7 @@ EarnedAchievementSchema.statics.createForAchievement = co.wrap (achievement, doc User = require('./User') userObjectID = doc.get(achievement.get('userField')) - userID = if _.isObject userObjectID then userObjectID.toHexString() else userObjectID # Standardize! Use ObjectIds + userID = if _.isObject userObjectID then userObjectID.toHexString() else userObjectID # TODO: Migrate to ObjectIds earnedAttrs = { user: userID diff --git a/spec/server/functional/achievement.spec.coffee b/spec/server/functional/achievement.spec.coffee index eecea44b4..af8b2c94c 100644 --- a/spec/server/functional/achievement.spec.coffee +++ b/spec/server/functional/achievement.spec.coffee @@ -233,6 +233,31 @@ describe 'POST /db/earned_achievement', -> user = yield User.findById(user.id) expect(user.get('earned').levels[0]).toBe(lockedLevelID) done() + + it 'updates the user\'s gems if the achievement gems changed', utils.wrap (done) -> + user = yield utils.initUser() + yield utils.loginUser(user) + + # get the User the unlockable achievement, check they got their reward + session = new LevelSession({ + permissions: simplePermissions + creator: user._id + level: original: 'dungeon-arena' + }) + yield session.save() + json = {achievement: @unlockable.id, triggeredBy: session._id, collection: 'level.sessions'} + [res, body] = yield request.postAsync { url: eaURL, json } + user = yield User.findById(user.id) + expect(user.get('earned').levels[0]).toBe(lockedLevelID) + + # change the achievement + yield @unlockable.update({ $set: { 'rewards.gems': 100 } }) + + # hit the endpoint again, make sure gems were updated + [res, body] = yield request.postAsync { url: eaURL, json } + user = yield User.findById(user.id) + expect(user.get('earned').gems).toBe(100) + done() describe 'automatically achieving achievements', -> beforeEach addAllAchievements From 3cdec61fe143c7658fa5f7152fb5f5e130e6a584 Mon Sep 17 00:00:00 2001 From: Robin Yang Date: Mon, 29 Aug 2016 11:22:19 -0700 Subject: [PATCH 06/15] Add Getting Started guide in Markdown - Replace PDF link on /teachers/resources - Add .pngs --- app/assets/markdown/getting-started.md | 151 ++++++++++++++++++ .../teachers/markdown-resource-view.sass | 30 +++- app/templates/teachers/resource-hub-view.jade | 3 +- 3 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 app/assets/markdown/getting-started.md diff --git a/app/assets/markdown/getting-started.md b/app/assets/markdown/getting-started.md new file mode 100644 index 000000000..246018fd1 --- /dev/null +++ b/app/assets/markdown/getting-started.md @@ -0,0 +1,151 @@ +##### Getting Started +# Start Using CodeCombat in 10 Minutes! +#### Get your class up and running with these steps. + +##### STEP 1 +## Create a Teacher Account + +After you’ve created your Teacher account, you will be able to create classes, invite students, monitor students’ progress, enroll students, and assign course content once students have been enrolled. + +Select the **Sign up as a Teacher** option during account creation in order to sign up as a teacher. + +create-teacher-account-modal + +Once your teacher account is setup, you’ll be able to access your [Teacher Dashboard](/teachers/classes). + +### What if I already have an account? + +If you already have a CodeCombat account as a Student or Individual but want to convert it to a Teacher account, visit the [Update to Teacher Account](/teachers/update-account) page. Once you’ve converted, your student account will be removed from any classrooms you may have previously joined. + +### What are the technical requirements for CodeCombat? +CodeCombat runs best on computers with at least 4GB of RAM, on a modern browser such as Chrome, Safari, Firefox, or Edge. Chromebooks with less RAM may have minor graphics issues in later courses. + +*We do not currently support iPads or Android Tablets at this time.* + +##### STEP 2 +## Create a New Class + +Once logged in, or if you click the Teacher link in the navigation bar, you’ll see your new [Teacher Dashboard](/teachers/classes). From here, you’ll be able to create classes and monitor your student’s progress. + +Click the blue “Create a New Class” button, then choose a class name that will help you and your students identify the class, such as “Mr. Smith 3rd period.” + +create-new-class-modal + + + +### Should I choose Python or JavaScript? +We recommend Python, because it’s both beginner-friendly and currently used by major corporations (such as Google). If you have younger or first-time learners, we strongly recommend Python. + +JavaScript will work great too. It’s the language of the web; used across every website, and still beginner-friendly. If you are planning to also study web development, you may prefer to choose JavaScript to avoid the confusion some students may have switching from one language’s syntax to another. However, JavaScript’s syntax is a little more difficult for beginners than Python. + +##### STEP 3 +## Add Students +Once you’ve created your class, you’ll see it under the list of Current Classes. Navigate the class where you want to add students, then choose one of three ways to add students. + +add-students +_Use your unique class code, class URL or invite students via email._ + + +### Option 1: Invite Students via Email +*Easiest option if your students have email addresses* +Click the blue "Invite Students by Email" button, then enter your students’ email addresses (you can copy and paste this from your class list or student information system) and click "Invite Students". Students will receive an email instructing them to follow a link, which will allow them to create an account and join your class. + +Make sure they are creating a **Student Account** and that the correct class name is displayed when they create their account. + +create-student-account +_Students should see your class name in place of "Test Class 1"._ + +Students will need to enter the following to create a Student Account: +- First name +- Last initial +- Username (help them choose an appropriate unique username) +- Password + +Email addresses are _not required_ for students creating an account when they have a valid Class Code from you. That said, using an email address is recommended if they have one, for easier password recovery. + +*If your school uses Google Apps for Education*, students can connect using the red "Google" button at the top of the screen instead of manually entering their information. They can then sign in to CodeCombat using the G+ Connect button in the future. + +After students have created their account, they are shown their username and instructed to write down this information. + +That’s it! Students can now use their login credentials to start playing CodeCombat! + +### Option 2: Students Join via a Class Code +*Easiest option if you students don’t have email addresses* + +Direct your students to [CodeCombat](https://codecombat.com) and have them click “Create Account” on the top navigation bar. Students should select the green "Sign up as a Student" button. + +create-student-account +_Make sure your students click “Sign up as a Student” when prompted._ + +The website will request the “Class Code” for your class, which can be found if you click “View Class” on your Teacher Dashboard. Your classroom will have its own unique three-word code. + +class-code-example +_How to find your Class Code as a Teacher: Click on one of your classes, and look for the three-word Class Code next under “Adding Students”._ + +Once students enter your Class Code, they should see the correct class name and instructor on the screen. + +Students will need to enter the following to create a Student Account: +- First name +- Last initial +- Username (help them choose an appropriate unique username) +- Password + +Email addresses are _not required_ for students creating an account when they have a valid Class Code from you. That said, using an email address is recommended if they have one, for easier password recovery. + +*If your school uses Google Apps for Education*, students can connect using the red "Google" button at the top of the screen instead of manually entering their information. They can then sign in to CodeCombat using the G+ Connect button in the future. + +After students have created their account, they are shown their username and instructed to write down this information. + +That’s it! Students can now use their login credentials to start playing CodeCombat! + +##### STEP 4 +## Introduction to Computer Science + +All students are automatically granted access to the first course in CodeCombat, Introduction to Computer Science. This is a course that introduces students to concepts such as basic syntax, variables, and while loops. Generally this course takes about 1-3 hours for a middle school class. + +course-guides +_Course Guides allow you to preview course levels and view solutions._ + +As a teacher, you can access solutions for each course by going to [Course Guides](/teachers/courses/) (located in the blue teacher navigation bar). You can also preview every level using the dropdown selectors. + +resource-hub +_Course Guides allow you to preview course levels and view solutions._ + +If you're new to teaching computer science, we also recommend taking a look at the [Resource Hub](/teachers/resources), where you can find lesson plans, worksheets and supplemental guides to help you kickstart your classroom. + +##### STEP 5 +## Tracking Progress + +student-overview +_A high-level view of student progress in each course is displayed in the main class view._ + +After students join the class, you’ll see their progress appear in the individual classroom pages. Any assigned courses and each student’s progress in each course (starting with CS1, Introduction to Computer Science and onwards) is represented by a colored circle. A grey circle means a student has not begun any levels in that course, yellow circle means they have started working on the course’s levels, and a green circle means that they’ve completed all of the levels in the course. + +student-progress +_Navigate to the "Course Progress" tab to view more detailed information of student progress within each course._ + +If you want to see how your students are doing within a course, click on the “Course Progress” tab. You’ll be able to view how much progress a student has made in a specific course. A gray circle means a level has not been started, a yellow circle indicates a level has been started but not completed, and a green circle means a level has been completed. By moving your mouse pointer over the circle for a level, you can see information about when they completed the level, as well as a rough estimate of how long the level took to complete. + +##### STEP 6 +## Licensing Students + +Students are required to have a license to access any content after the first course. When you assign a new course, a license will automatically be applied to the student. By default, all licenses expire one year from when they are granted. A single license allows a single student access to all of the courses available. + +To manually assign a license to a student, click on the *License Status* tab while viewing a class and use the "Assign License" button. + +### How do I get Licenses for my students? + +If you would like to purchase more licenses, click on *Student Licenses* in the Teacher Dashboard navigation bar, and follow the instructions under "Get More Licenses". One of CodeCombat’s specialists will be in contact with you shortly to discuss your needs. + +##### STEP 7 +## Assigning Courses + +bulk-assign + +Once a student is enrolled, you’ll be able to assign additional courses to them. We recommend not assigning students to more than one course ahead of where they currently are. You can bulk-assign a course to multiple students at a time by selecting students using checkboxes on the left-hand side (or the “Select All” checkbox), then choosing the appropriate course from the dropdown menu, and then clicking “Assign to Selected Students.” + +##### STEP 8 +## Start Teaching! +There are great supplemental materials for teachers available on our [Course Guides](/teachers/courses/) and [Resource Hub](/teachers/resources). If you're new to teaching computer science, we highly recommend checking these out -- we've built these with first-time teachers in mind. You can also browse our [Teacher Forums](https://discourse.codecombat.com/c/teachers), where you can discuss curriculum planning with other educators, share ideas, or ask questions. + +You can also email us at [schools@codecombat.com](mailto:schools@codecombat.com) with any support questions or concerns! diff --git a/app/styles/teachers/markdown-resource-view.sass b/app/styles/teachers/markdown-resource-view.sass index e81bc8e14..214e32a11 100644 --- a/app/styles/teachers/markdown-resource-view.sass +++ b/app/styles/teachers/markdown-resource-view.sass @@ -6,7 +6,6 @@ .print, .print p text-align: center font-size: .75em - // text-transform: uppercase @media print display: none @@ -17,7 +16,7 @@ .back-to-top text-transform: none @media print - display: none + display: none .lesson-plans max-width: 900px @@ -44,6 +43,8 @@ padding: 0 font-family: Open Sans font-weight: 200 + @media print + font-size: 2em // module header // h2 @@ -70,21 +71,25 @@ margin: 0 // make it so the page breaks before each module - h5[id^="module"] + h5[id^="module"], h5[id^="step"] border-top: 1px solid #666 margin: 30px 0 0 0 padding: 20px 0 0 0 @media print page-break-before: always + h5[id="step-1"] + border-top: none !important + @media print + page-break-before: avoid + .back-to-top + display: none + h6 font-style: italic font-weight: 200 text-align: right - img - width: 100% - table, tr, td, th border: 2px solid #f8f8f8 border-radius: 5px @@ -107,3 +112,16 @@ margin: 0 0 0 0px padding: 0 60px 0 50px border-left: 5px solid #eee + + + + // Styles for Getting Started page. + #getting-started + img + max-width: 400px + border: 1px solid #666 + box-shadow: 2px 2px #ddd + margin: 15px auto 15px auto + display: block + @media print + max-width: 400px !important diff --git a/app/templates/teachers/resource-hub-view.jade b/app/templates/teachers/resource-hub-view.jade index c4f7c9464..5adcd7dc8 100644 --- a/app/templates/teachers/resource-hub-view.jade +++ b/app/templates/teachers/resource-hub-view.jade @@ -14,9 +14,8 @@ block content h4(data-i18n="teacher.getting_started") ul li - a(href="http://files.codecombat.com/docs/resources/TeacherGettingStartedGuide.pdf" target="blank") + a(href="/teachers/resources/getting-started") span(data-i18n="teacher.teacher_getting_started") - span.spl [PDF] p(data-i18n="teacher.teacher_getting_started_desc") li a(href="http://files.codecombat.com/docs/resources/StudentQuickStartGuide.pdf" target="blank") From c4dcf0f756ce89787df12d1136a1e8ddfa48fbcc Mon Sep 17 00:00:00 2001 From: Robin Yang Date: Mon, 29 Aug 2016 18:13:07 -0700 Subject: [PATCH 07/15] Remove Java from homepage, Update languages --- app/locale/en.coffee | 2 +- app/templates/new-home-view.jade | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 48ee53178..ed66d92a4 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -72,7 +72,7 @@ ffa: "Free for all students" lesson_time: "Lesson time:" coming_soon: "More coming soon!" - courses_available_in: "Courses are available in JavaScript, Python, and Java (coming soon!)" + courses_available_in: "Courses are available in JavaScript and Python. Web Development courses utilize HTML, CSS, jQuery, and Bootstrap." #{change} 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." run_class:"Everything you need to run a computer science class in your school today, no CS background required." diff --git a/app/templates/new-home-view.jade b/app/templates/new-home-view.jade index 927915c5b..d81ae6bc2 100644 --- a/app/templates/new-home-view.jade +++ b/app/templates/new-home-view.jade @@ -269,7 +269,7 @@ block content .clearfix .text-center h4 - img(src="/images/pages/home/course_languages.png") + img(src="/images/pages/about/new_languages.png") div(data-i18n="new_home.courses_available_in") .testimonials-rows From ee3ce24d6c2db68a675bedae8aabd22c9119195d Mon Sep 17 00:00:00 2001 From: Matt Lott Date: Tue, 30 Aug 2016 09:21:48 -0700 Subject: [PATCH 08/15] :bug:Fix email case check in followup leads script --- scripts/followupCloseIoLeads.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/followupCloseIoLeads.js b/scripts/followupCloseIoLeads.js index 1334c605a..988741dd9 100644 --- a/scripts/followupCloseIoLeads.js +++ b/scripts/followupCloseIoLeads.js @@ -257,7 +257,7 @@ function createSendFollowupMailFn(userApiKeyMap, latestDate, lead, email) { for (const activity of results.data) { if (activity.id === firstMailActivity.id) continue; if (new Date(firstMailActivity.date_created) > new Date(activity.date_created)) continue; - if (activity._type === 'Email' && activity.to[0] !== email) continue; + if (activity._type === 'Email' && activity.to[0].toLowerCase() !== email) continue; recentActivity = activity; break; } @@ -269,7 +269,6 @@ function createSendFollowupMailFn(userApiKeyMap, latestDate, lead, email) { return done(); } // console.log(`TODO: ${firstMailActivity.to[0]} ${lead.id} ${firstMailActivity.contact_id} ${template} ${userApiKeyMap[firstMailActivity.user_id]}`); - // console.log(`TODO: ${firstMailActivity.to[0]} ${lead.id}`); sendMail(firstMailActivity.to[0], lead.id, firstMailActivity.contact_id, template, userApiKeyMap[firstMailActivity.user_id], 0, (err) => { if (err) return done(err); @@ -302,7 +301,7 @@ function createSendFollowupMailFn(userApiKeyMap, latestDate, lead, email) { } else { // console.log(`Found recent activity after auto1 mail for ${lead.id}`); - // console.log(firstMailActivity.template_id, recentActivity.template_id); + // console.log(firstMailActivity.template_id, recentActivity); return done(); } } @@ -387,7 +386,6 @@ function sendSecondFollowupMails(done) { function createAddCallTaskFn(userApiKeyMap, latestDate, lead, email) { // Check for activity since second auto mail and status update // Add call task - // TODO: Very similar function to createSendFollowupMailFn const auto1Statuses = ["Auto Attempt 1", "New US Schools Auto Attempt 1", "Inbound Canada Auto Attempt 1", "Inbound AU Auto Attempt 1", "Inbound NZ Auto Attempt 1", "Inbound UK Auto Attempt 1"]; const auto2Statuses = ["Auto Attempt 2", "New US Schools Auto Attempt 2", "Inbound Canada Auto Attempt 2", "Inbound AU Auto Attempt 2", "Inbound NZ Auto Attempt 2", "Inbound UK Auto Attempt 2"]; return (done) => { From be11c47e17d6c08d6d5f0a87a2fe81afa428ae08 Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Tue, 30 Aug 2016 10:49:45 -0700 Subject: [PATCH 09/15] Have campaign-view translate campaign name normally --- app/templates/play/campaign-view.jade | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/templates/play/campaign-view.jade b/app/templates/play/campaign-view.jade index 816b767f3..a0d3cb7d2 100644 --- a/app/templates/play/campaign-view.jade +++ b/app/templates/play/campaign-view.jade @@ -200,10 +200,7 @@ if view.showAds() h1#campaign-status.picoctf-hide .campaign-status-background .campaign-name - - var fullName = i18n(campaign.attributes, 'fullName') - if (me.get('preferredLanguage', true) || 'en-US').split('-')[0] == 'en' || fullName != campaign.get('fullName') - // We have a translation. - span= fullName + span= i18n(campaign.attributes, 'fullName') .levels-completed span= levelsCompleted | / From 652c5237aae70652a2c1770d53af940e1ee72150 Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Tue, 30 Aug 2016 14:17:40 -0700 Subject: [PATCH 10/15] Fix updateI18NCoverage, handles i18n objects with nothing to translate, fix #2630 --- app/models/CocoModel.coffee | 6 +--- test/app/models/CocoModel.spec.coffee | 50 ++++++++++++++++++++------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/app/models/CocoModel.coffee b/app/models/CocoModel.coffee index d6492218a..8a8e2f341 100644 --- a/app/models/CocoModel.coffee +++ b/app/models/CocoModel.coffee @@ -416,11 +416,7 @@ class CocoModel extends Backbone.Model # use it to determine what properties actually need to be translated props = workingSchema.props or [] props = (prop for prop in props when parentData[prop]) - #unless props.length - # console.log 'props is', props, 'path is', path, 'data is', data, 'parentData is', parentData, 'workingSchema is', workingSchema - # langCodeArrays.push _.without _.keys(locale), 'update' # Every language has covered a path with no properties to be translated. - # return - + return unless props.length return if 'additionalProperties' of i18n # Workaround for #2630: Programmable is weird # get a list of lang codes where its object has keys for every prop to be translated diff --git a/test/app/models/CocoModel.spec.coffee b/test/app/models/CocoModel.spec.coffee index e373b777d..2d35029ce 100644 --- a/test/app/models/CocoModel.spec.coffee +++ b/test/app/models/CocoModel.spec.coffee @@ -151,23 +151,49 @@ describe 'CocoModel', -> describe 'updateI18NCoverage', -> class FlexibleClass extends CocoModel @className: 'Flexible' - @schema: {} + @schema: { + type: 'object' + properties: { + name: { type: 'string' } + description: { type: 'string' } + innerObject: { + type: 'object' + properties: { + name: { type: 'string' } + i18n: { type: 'object', format: 'i18n', props: ['name']} + } + } + i18n: { type: 'object', format: 'i18n', props: ['description', 'name', 'prop1']} + } + } it 'only includes languages for which all objects include a translation', -> m = new FlexibleClass({ - i18n: { es: {}, fr: {} } - prop1: 1 - prop2: 'string' - prop3: true + i18n: { es: { name: '+', description: '+' }, fr: { name: '+', description: '+' } } + name: 'Name' + description: 'Description' innerObject: { - i18n: { es: {}, de: {}, fr: {} } - prop4: [ - { - i18n: { es: {} } - } - ] + i18n: { es: { name: '+' }, de: { name: '+' }, fr: {} } + name: 'Name' } }) m.updateI18NCoverage() - expect(JSON.stringify(m.get('i18nCoverage'))).toBe('["es"]') + expect(_.isEqual(m.get('i18nCoverage'), ['es'])).toBe(true) + + it 'ignores objects for which there is nothing to translate', -> + m = new FlexibleClass() + m.set({ + name: 'Name' + i18n: { + '-': {'-':'-'} + 'es': {name: 'Name in Spanish'} + } + innerObject: { + i18n: { '-': {'-':'-'} } + } + }) + m.updateI18NCoverage() + expect(_.isEqual(m.get('i18nCoverage'), ['es'])).toBe(true) + + From 3387e5ee34cbb6fbf538794e5b13cbf3114b0871 Mon Sep 17 00:00:00 2001 From: Matt Lott Date: Tue, 30 Aug 2016 15:32:22 -0700 Subject: [PATCH 11/15] :bug:Don't overwrite close.io nces custom field data --- scripts/updateCloseIoLeads.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/scripts/updateCloseIoLeads.js b/scripts/updateCloseIoLeads.js index a67f53bb2..6e6ed2569 100644 --- a/scripts/updateCloseIoLeads.js +++ b/scripts/updateCloseIoLeads.js @@ -506,20 +506,11 @@ class CocoLead { if (!currentCustom['Lead Origin']) { putData['custom.Lead Origin'] = this.getLeadOrigin(); } - for (const email in this.contacts) { const props = this.contacts[email].trial.properties; if (props) { - let haveNcesData = false; for (const prop in props) { - if (/nces_/ig.test(prop)) { - haveNcesData = true; - putData[`custom.demo_${prop}`] = props[prop]; - } - } - for (const prop in props) { - // Always overwrite common props if we have NCES data, because other fields more likely to be accurate - if (commonTrialProperties.indexOf(prop) >= 0 && (haveNcesData || !currentCustom[`demo_${prop}`] || currentCustom[`demo_${prop}`] !== props[prop] && currentCustom[`demo_${prop}`].indexOf(props[prop]) < 0)) { + if (!currentCustom[`demo_${prop}`] && (commonTrialProperties.indexOf(prop) >= 0 || /nces_/ig.test(prop))) { putData[`custom.demo_${prop}`] = props[prop]; } } From f57ec17261981bc5a5e8b3be5c27a471067ba0d8 Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Tue, 30 Aug 2016 16:05:25 -0700 Subject: [PATCH 12/15] Fix play buttons not appearing sometimes on navigation Repro steps: * Go to a ladder page * Click to play * Click link back to ladder * Before, the play buttons would not appear, now they do. --- app/views/ladder/LadderView.coffee | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/ladder/LadderView.coffee b/app/views/ladder/LadderView.coffee index d192a892a..cc55b34fb 100644 --- a/app/views/ladder/LadderView.coffee +++ b/app/views/ladder/LadderView.coffee @@ -41,10 +41,12 @@ module.exports = class LadderView extends RootView initialize: (options, @levelID, @leagueType, @leagueID) -> @level = @supermodel.loadModel(new Level(_id: @levelID)).model - @level.once 'sync', => + onLoaded = => return if @destroyed @levelDescription = marked(@level.get('description')) if @level.get('description') @teams = teamDataFromLevel @level + + if @level.loaded then onLoaded() else @level.once('sync', onLoaded) @sessions = @supermodel.loadCollection(new LevelSessionsCollection(@levelID), 'your_sessions', {cache: false}).model @winners = require('./tournament_results')[@levelID] From 9e246709936b7a18fa338cc5cf9a92309552532a Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Tue, 30 Aug 2016 16:17:58 -0700 Subject: [PATCH 13/15] Tweak README wording --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 0e451ff48..bde2f934e 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,7 @@ 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.** +setup guide, extensive documentation, and much more to get started hacking!** 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 From 275f708041632d158974e9c5a5a2e20581d0a0e8 Mon Sep 17 00:00:00 2001 From: Matt Lott Date: Tue, 30 Aug 2016 16:19:38 -0700 Subject: [PATCH 14/15] :bug:Fix campaign editor next level arrows --- app/views/play/CampaignView.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/play/CampaignView.coffee b/app/views/play/CampaignView.coffee index 98223d550..a897bb7e2 100644 --- a/app/views/play/CampaignView.coffee +++ b/app/views/play/CampaignView.coffee @@ -371,8 +371,8 @@ module.exports = class CampaignView extends RootView for level in orderedLevels # Iterate through all levels in order and look to find the first unlocked one that meets all our criteria for being pointed out as the next level. level.nextLevels = (reward.level for reward in level.rewards ? [] when reward.level) - break if foundNext = findNextLevel(level.nextLevels, true) # Check practice levels first - break if foundNext = findNextLevel(level.nextLevels, false) + foundNext = findNextLevel(level.nextLevels, true) unless foundNext # Check practice levels first + foundNext = findNextLevel(level.nextLevels, false) unless foundNext if not foundNext and orderedLevels[0] and not orderedLevels[0].locked and @levelStatusMap[orderedLevels[0].slug] isnt 'complete' orderedLevels[0].next = true From a7d51d672a637f04acc8ff27819f6b5788c6da93 Mon Sep 17 00:00:00 2001 From: Matt Lott Date: Wed, 31 Aug 2016 08:04:58 -0700 Subject: [PATCH 15/15] Remove email activities from close lead export script --- scripts/sendExportedLeads.js | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/scripts/sendExportedLeads.js b/scripts/sendExportedLeads.js index 272c510cc..c66a4b14a 100644 --- a/scripts/sendExportedLeads.js +++ b/scripts/sendExportedLeads.js @@ -128,23 +128,6 @@ function activity2Html(activity) { } html += "

"; } - else if (activity._type === 'Email') { - html += `

${activity._type}

`; - if (activity.subject) html += `

${activity.subject}

`; - if (activity.date_updated) html += `
Updated: ${activity.date_updated}
`; - if (activity.opens_summary) html += `
${activity.opens_summary}
`; - if (activity.status) html += `
Status: ${activity.status}
`; - for (let email of activity.to) { - html += `
To: ${email}
`; - } - if (activity.sender) html += `
From: ${activity.sender}
`; - const lines = (activity.body_text || '').split('\n'); - html += "

"; - for (const line of lines) { - html += `

${line}
`; - } - html += "

"; - } return html; }