Merge branch 'rubenvereecken-achievements_production'

This commit is contained in:
Scott Erickson 2014-06-02 13:16:03 -07:00
commit f114d71d9d
36 changed files with 837 additions and 56 deletions

12
achievement_fixtures.js Normal file
View file

@ -0,0 +1,12 @@
// Fixtures
db.achievements.insert({
query: '{"level.original": "52d97ecd32362bc86e004e87"}',
index: true,
slug: 'dungeon-arena-started',
name: 'Dungeon Arena started',
worth: 1,
collection: 'level.session',
description: 'Started playing Dungeon Arena.',
userField: 'creator'
});

View file

@ -5,6 +5,7 @@ locale = require 'locale/locale'
{me} = require 'lib/auth'
Tracker = require 'lib/Tracker'
CocoView = require 'views/kinds/CocoView'
AchievementNotify = require '../../templates/achievement_notify'
marked.setOptions {gfm: true, sanitize: true, smartLists: true, breaks: false}
@ -39,6 +40,7 @@ Application = initialize: ->
@facebookHandler = new FacebookHandler()
@gplusHandler = new GPlusHandler()
$(document).bind 'keydown', preventBackspace
$.notify.addStyle 'achievement', html: $(AchievementNotify())
@linkedinHandler = new LinkedInHandler()
preload(COMMON_FILES)
$.i18n.init {

View file

@ -0,0 +1,7 @@
CocoCollection = require 'collections/CocoCollection'
class NewAchievementCollection extends CocoCollection
initialize: (me = require('lib/auth').me) ->
@url = "/db/user/#{me.id}/achievements?notified=false"
module.exports = NewAchievementCollection

View file

@ -59,4 +59,4 @@ setUpChannels = ->
setUpDefinitions = ->
for definition of definitionSchemas
Backbone.Mediator.addDefSchemas definitionSchemas[definition]
Backbone.Mediator.addDefSchemas definitionSchemas[definition]

44
app/lib/LocalMongo.coffee Normal file
View file

@ -0,0 +1,44 @@
LocalMongo = module.exports
# Checks whether func(l, r) is true for at least one value of left for at least one value of right
mapred = (left, right, func) ->
_.reduce(left, ((result, singleLeft) ->
result or (_.reduce (_.map right, (singleRight) -> func(singleLeft, singleRight)),
((intermediate, value) -> intermediate or value), false)), false)
doQuerySelector = (value, operatorObj) ->
value = [value] unless _.isArray value # left hand can be an array too
for operator, body of operatorObj
body = [body] unless _.isArray body # right hand can be an array too
switch operator
when '$gt' then return false unless mapred value, body, (l, r) -> l > r
when '$gte' then return false unless mapred value, body, (l, r) -> l >= r
when '$lt' then return false unless mapred value, body, (l, r) -> l < r
when '$lte' then return false unless mapred value, body, (l, r) -> l <= r
when '$ne' then return false if mapred value, body, (l, r) -> l == r
when '$in' then return false unless _.reduce value, ((result, val) -> result or val in body), false
when '$nin' then return false if _.reduce value, ((result, val) -> result or val in body), false
else return false
true
matchesQuery = (target, queryObj) ->
return true unless queryObj
for prop, query of queryObj
if prop[0] == '$'
switch prop
when '$or' then return false unless _.reduce query, ((res, obj) -> res or matchesQuery target, obj), false
when '$and' then return false unless _.reduce query, ((res, obj) -> res and matchesQuery target, obj), true
else return false
else
# Do nested properties
pieces = prop.split('.')
obj = target
for piece in pieces
return false unless piece of obj
obj = obj[piece]
if typeof query != 'object' or _.isArray query
return false unless obj == query or (query in obj if _.isArray obj)
else return false unless doQuerySelector obj, query
true
LocalMongo.matchesQuery = matchesQuery

View file

@ -67,3 +67,11 @@ module.exports.i18n = (say, target, language=me.lang(), fallback='en') ->
return fallbackResult if fallbackResult?
return say[target] if target of say
null
module.exports.getByPath = (target, path) ->
pieces = path.split('.')
obj = target
for piece in pieces
return undefined unless piece of obj
obj = obj[piece]
obj

View file

@ -458,6 +458,7 @@
thang_description: "Build units, defining their default logic, graphics and audio. Currently only supports importing Flash exported vector graphics."
level_title: "Level Editor"
level_description: "Includes the tools for scripting, uploading audio, and constructing custom logic to create all sorts of levels. Everything we use ourselves!"
achievement_title: "Achievement Editor"
got_questions: "Questions about using the CodeCombat editors?"
contact_us: "Contact us!"
hipchat_prefix: "You can also find us in our"
@ -503,9 +504,12 @@
new_article_title_login: "Log In to Create a New Article"
new_thang_title_login: "Log In to Create a New Thang Type"
new_level_title_login: "Log In to Create a New Level"
new_achievement_title: "Create a New Achievement"
new_achievement_title_login: "Sign Up to Create a New Achievement"
article_search_title: "Search Articles Here"
thang_search_title: "Search Thang Types Here"
level_search_title: "Search Levels Here"
achievement_search_title: "Search Achievements"
read_only_warning2: "Note: you can't save any edits here, because you're not logged in."
article:

View file

@ -0,0 +1,9 @@
CocoModel = require './CocoModel'
module.exports = class Achievement extends CocoModel
@className: 'Achievement'
@schema: require 'schemas/models/achievement'
urlRoot: '/db/achievement'
isRepeatable: ->
@get('proportionalTo')?

View file

@ -1,6 +1,8 @@
storage = require 'lib/storage'
deltasLib = require 'lib/deltas'
NewAchievementCollection = require '../collections/NewAchievementCollection'
class CocoModel extends Backbone.Model
idAttribute: "_id"
loaded: false
@ -86,6 +88,7 @@ class CocoModel extends Backbone.Model
success(@, res) if success
@markToRevert() if @_revertAttributes
@clearBackup()
CocoModel.pollAchievements()
options.error = (model, res) =>
error(@, res) if error
return unless @notyErrors
@ -266,4 +269,14 @@ class CocoModel extends Backbone.Model
getURL: ->
return if _.isString @url then @url else @url()
@pollAchievements: ->
achievements = new NewAchievementCollection
achievements.fetch(
success: (collection) ->
me.fetch (success: -> Backbone.Mediator.publish('achievements:new', collection)) unless _.isEmpty(collection.models)
)
CocoModel.pollAchievements = _.debounce CocoModel.pollAchievements, 500
module.exports = CocoModel

View file

@ -74,3 +74,21 @@ module.exports = class User extends CocoModel
@set('emails', newSubs)
isEmailSubscriptionEnabled: (name) -> (@get('emails') or {})[name]?.enabled
a = 5
b = 40
# y = a * ln(1/b * (x + b)) + 1
@levelFromExp: (xp) ->
if xp > 0 then Math.floor(a * Math.log((1/b) * (xp + b))) + 1 else 1
# x = (e^((y-1)/a) - 1) * b
@expForLevel: (level) ->
Math.ceil((Math.exp((level - 1)/ a) - 1) * b)
level: ->
User.levelFromExp(@get('points'))
levelFromExp: (xp) -> User.levelFromExp(xp)
expForLevel: (level) -> User.expForLevel(level)

View file

@ -0,0 +1,76 @@
c = require './../schemas'
# TODO add these: http://docs.mongodb.org/manual/reference/operator/query/
MongoQueryOperatorSchema =
title: 'MongoDB Query operator'
id: 'mongoQueryOperator'
type: 'object'
properties:
'$gt': type: 'number'
'$gte': type: 'number'
'$in': type: 'array'
'$lt': type: 'number'
'$lte': type: 'number'
'$ne': type: [ 'number', 'string' ]
'$nin': type: 'array'
additionalProperties: true # TODO set to false when the schema's done
MongoFindQuerySchema =
title: 'MongoDB Query'
id: 'mongoFindQuery'
type: 'object'
patternProperties:
#'^[-a-zA-Z0-9_]*$':
'^[-a-zA-Z0-9\.]*$':
oneOf: [
#{ $ref: '#/definitions/' + MongoQueryOperatorSchema.id},
{ type: 'string' }
{ type: 'object' }
]
additionalProperties: true # TODO make Treema accept new pattern matched keys
definitions: {}
MongoFindQuerySchema.definitions[MongoQueryOperatorSchema.id] = MongoQueryOperatorSchema
AchievementSchema = c.object()
c.extendNamedProperties AchievementSchema
c.extendBasicProperties AchievementSchema, 'article'
c.extendSearchableProperties AchievementSchema
_.extend(AchievementSchema.properties,
query:
#type:'object'
$ref: '#/definitions/' + MongoFindQuerySchema.id
worth: { type: 'number' }
collection: { type: 'string' }
description: { type: 'string' }
userField: { type: 'string' }
related: c.objectId(description: 'Related entity')
icon: { type: 'string', format: 'image-file', title: 'Icon' }
proportionalTo:
type: 'string'
description: 'For repeatables only. Denotes the field a repeatable achievement needs for its calculations'
function:
type: 'object'
oneOf: [
linear:
type: 'object'
properties:
a: {type: 'number', default: 1},
required: ['a']
description: 'f(x) = a * x'
logarithmic:
type:'object'
properties:
a: {type: 'number', default: 1}
b: {type: 'number', default: 1}
required: ['a', 'b']
description: 'f(x) = a * ln(1/b * (x + b))'
]
default: linear: a: 1
)
AchievementSchema.definitions = {}
AchievementSchema.definitions[MongoFindQuerySchema.id] = MongoFindQuerySchema
module.exports = AchievementSchema

View file

@ -0,0 +1,34 @@
c = require './../schemas'
module.exports =
EarnedAchievementSchema =
type: 'object'
properties:
user: c.objectId
links:
[
{
rel: 'extra'
href: "/db/user/{($)}"
}
]
achievement: c.objectId
links:
[
{
rel: 'extra'
href: '/db/achievement/{($)}'
}
]
collection:
type: 'string'
achievementName:
type: 'string'
created:
type: 'date'
changed:
type: 'date'
achievedAmount:
type: 'number'
notified:
type: 'boolean'

View file

@ -114,6 +114,7 @@ UserSchema = c.object {},
linkedinID: c.shortString {title:"LinkedInID", description: "The user's LinkedIn ID when they signed the contract."}
date: c.date {title: "Date signed employer agreement"}
data: c.object {description: "Cached LinkedIn data slurped from profile."}
points: {type:'number'}
c.extendBasicProperties UserSchema, 'user'

View file

@ -17,7 +17,7 @@ me.shortString = (ext) -> combine({type: 'string', maxLength: 100}, ext)
me.pct = (ext) -> combine({type: 'number', maximum: 1.0, minimum: 0.0}, ext)
me.date = (ext) -> combine({type: ['object', 'string'], format: 'date-time'}, ext)
# should just be string (Mongo ID), but sometimes mongoose turns them into objects representing those, so we are lenient
me.objectId = (ext) -> schema = combine({type: ['object', 'string'] }, ext)
me.objectId = (ext) -> schema = combine(['object', 'string'], ext)
me.url = (ext) -> combine({type: 'string', format: 'url', pattern: urlPattern}, ext)
PointSchema = me.object {title: "Point", description: "An {x, y} coordinate point.", format: "point2d", required: ["x", "y"]},
@ -48,7 +48,7 @@ me.colorConfig = (props) ->
# BASICS
basicProps = (linkFragment) ->
_id: me.objectId(links: [{rel: 'self', href: "/db/#{linkFragment}/{($)}"}], format: 'hidden')
_id: me.objectId(links: [{rel: 'self', href: "/db/#{linkFragment}/{($)}"}], format:"hidden")
__v: { title: 'Mongoose Version', format: 'hidden' }
me.extendBasicProperties = (schema, linkFragment) ->

47
app/styles/notify.sass Normal file
View file

@ -0,0 +1,47 @@
.notifyjs-achievement-base
//background: url("/images/pages/base/notify_mockup.png")
background-image: url("/images/pages/base/modal_background.png")
background-size: 100% 100%
width: 500px
height: 200px
padding: 35px 35px 15px 15px
text-align: center
cursor: auto
.achievement-body
.achievement-image
img
float: left
width: 100px
height: 100px
border-radius: 50%
margin: 20px 30px 20px 30px
-webkit-box-shadow: 0px 0px 36px 0px white
-moz-box-shadow: 0px 0px 36px 0px white
box-shadow: 0px 0px 36px 0px white
.achievement-title
font-family: Bangers
font-size: 28px
.achievement-description
margin-top: 10px
font-size: 16px
.achievement-progress
padding: 15px 0px 0px 0px
.achievement-message
font-family: Bangers
font-size: 18px
&:empty
display: none
.progress-wrapper
.progress-bar-wrapper
width: 100%
.earned-exp
padding-left: 5px
font-family: Bangers
font-size: 16px
float: right

View file

@ -0,0 +1,12 @@
div
.clearfix.achievement-body
.achievement-image(data-notify-html="image")
.achievement-content
.achievement-title(data-notify-html="title")
.achievement-description(data-notify-html="description")
.achievement-progress
.achievement-message(data-notify-html="message")
.progress-wrapper
.earned-exp(data-notify-html="earnedExp")
.progress-bar-wrapper(data-notify-html="progressBar")

View file

@ -12,7 +12,7 @@ body
a.navbar-brand(href='/')
img(src="/images/pages/base/logo.png", title="CodeCombat - Learn how to code by playing a game", alt="CodeCombat")
.collapse.navbar-collapse#collapsible-navbar
.collapse.navbar-collapse#collapsible-navbar
ul.nav.navbar-nav
li.play
a.header-font(href='/play', data-i18n="nav.play") Levels

View file

@ -0,0 +1,29 @@
extends /templates/base
block content
if me.isAdmin()
div
ol.breadcrumb
li
a(href="/editor", data-i18n="editor.main_title") CodeCombat Editors
li
a(href="/editor/achievement", data-i18n="editor.achievement_title") Achievement Editor
li.active
| #{achievement.attributes.name}
button(data-i18n="common.save", disabled=authorized === true ? undefined : "true").btn.btn-primary#save-button Save
h3(data-i18n="achievement.edit_achievement_title") Edit Achievement
span
|: "#{achievement.attributes.name}"
#achievement-treema
#achievement-view
hr
div#error-view
else
.alert.alert-danger
span Admin only. Turn around.

View file

@ -0,0 +1,17 @@
extends /templates/kinds/table
block tableHeader
tr
th(data-i18n="general.name") Name
th(data-i18n="general.description") Description
th(data-i18n="general.collection") Collection
block tableBody
for data in documents
- data = data.attributes
tr
td
a(href="/editor/achievement/#{data.slug || data._id}")
| #{data.name}
td #{data.description}
td #{data.collection}

View file

@ -1,38 +1,43 @@
extends /templates/base
block content
div
ol.breadcrumb
li
a(href="/editor", data-i18n="editor.main_title") CodeCombat Editors
li.active(data-i18n="#{currentEditor}")
| #{currentEditor}
if !unauthorized
div
ol.breadcrumb
li
a(href="/editor", data-i18n="editor.main_title") CodeCombat Editors
li.active(data-i18n="#{currentEditor}")
| #{currentEditor}
if me.get('anonymous')
a.btn.btn-primary.open-modal-button(data-toggle="coco-modal", data-target="modal/auth", role="button", data-i18n="#{currentNewSignup}") Log in to Create a New Content
if me.get('anonymous')
a.btn.btn-primary.open-modal-button(data-toggle="coco-modal", data-target="modal/auth", role="button", data-i18n="#{currentNewSignup}") Log in to Create a New Content
else
a.btn.btn-primary.open-modal-button(href='#new-model-modal', role="button", data-toggle="modal", data-i18n="#{currentNew}") Create a New Something
input#search(data-i18n="[placeholder]#{currentSearch}")
hr
div.results
table
// TODO: make this into a ModalView subview
div.modal.fade#new-model-modal
.modal-dialog
.background-wrapper
.modal-content
.modal-header
h3(data-i18n="#{currentNew}") Create New #{modelLabel}
.modal-body
form.form
.form-group
label.control-label(for="name", data-i18n="general.name") Name
input#name.form-control(name="name", type="text")
.modal-footer
button.btn(data-dismiss="modal", data-i18n="common.cancel") Cancel
button.btn.btn-primary.new-model-submit(data-i18n="common.create") Create
.modal-body.wait.secret
h3(data-i18n="play_level.tip_reticulating") Reticulating Splines...
.progress.progress-striped.active
.progress-bar
else
a.btn.btn-primary.open-modal-button(href='#new-model-modal', role="button", data-toggle="modal", data-i18n="#{currentNew}") Create a New Something
input#search(data-i18n="[placeholder]#{currentSearch}")
hr
div.results
table
// TODO: make this into a ModalView subview
div.modal.fade#new-model-modal
.modal-dialog
.background-wrapper
.modal-content
.modal-header
h3(data-i18n="#{currentNew}") Create New #{modelLabel}
.modal-body
form.form
.form-group
label.control-label(for="name", data-i18n="general.name") Name
input#name.form-control(name="name", type="text")
.modal-footer
button.btn(data-dismiss="modal", data-i18n="common.cancel") Cancel
button.btn.btn-primary.new-model-submit(data-i18n="common.create") Create
.modal-body.wait.secret
h3(data-i18n="play_level.tip_reticulating") Reticulating Splines...
.progress.progress-striped.active
.progress-bar
.alert.alert-danger
span Admin only. Turn around.
// TODO Ruben prettify

View file

@ -5,17 +5,19 @@ table.table
| Results
span
|: #{documents.length}
tr
th(data-i18n="general.name") Name
th(data-i18n="general.description") Description
th(data-i18n="general.version") Version
for data in documents
- data = data.attributes;
block tableHeader
tr
td
a(href="/editor/#{page}/#{data.slug || data._id}")
| #{data.name}
td.body-row #{data.description}
td #{data.version.major}.#{data.version.minor}
th(data-i18n="general.name") Name
th(data-i18n="general.description") Description
th(data-i18n="general.version") Version
block tableBody
for data in documents
- data = data.attributes;
tr
td
a(href="/editor/#{page}/#{data.slug || data._id}")
| #{data.name}
td.body-row #{data.description}
td #{data.version.major}.#{data.version.minor}

View file

@ -0,0 +1,74 @@
View = require 'views/kinds/RootView'
template = require 'templates/editor/achievement/edit'
Achievement = require 'models/Achievement'
module.exports = class AchievementEditView extends View
id: "editor-achievement-edit-view"
template: template
startsLoading: true
events:
'click #save-button': 'saveAchievement'
subscriptions:
'save-new': 'saveAchievement'
constructor: (options, @achievementID) ->
super options
@achievement = new Achievement(_id: @achievementID)
@achievement.saveBackups = true
@listenToOnce(@achievement, 'error',
() =>
@hideLoading()
$(@$el).find('.main-content-area').children('*').not('#error-view').remove()
@insertSubView(new ErrorView())
)
@achievement.fetch()
@listenToOnce(@achievement, 'sync', @buildTreema)
@pushChangesToPreview = _.throttle(@pushChangesToPreview, 500)
buildTreema: ->
return if @treema? or (not @achievement.loaded)
@startsLoading = false
@render()
data = $.extend(true, {}, @achievement.attributes)
options =
data: data
filePath: "db/achievement/#{@achievement.get('_id')}"
schema: Achievement.schema
readOnly: me.get('anonymous')
callbacks:
change: @pushChangesToPreview
@treema = @$el.find('#achievement-treema').treema(options)
@treema.build()
pushChangesToPreview: =>
'TODO' # TODO might want some intrinsic preview thing
getRenderData: (context={}) ->
context = super(context)
context.achievement = @achievement
context.authorized = me.isAdmin()
context
openSaveModal: ->
'Maybe later' # TODO
saveAchievement: (e) ->
@treema.endExistingEdits()
for key, value of @treema.data
@achievement.set(key, value)
res = @achievement.save()
res.error (collection, response, options) =>
console.error response
res.success =>
url = "/editor/achievement/#{@achievement.get('slug') or @achievement.id}"
document.location.href = url

View file

@ -0,0 +1,19 @@
SearchView = require 'views/kinds/SearchView'
module.exports = class AchievementSearchView extends SearchView
id: "editor-achievement-home-view"
modelLabel: "Achievement"
model: require 'models/Achievement'
modelURL: '/db/achievement'
tableTemplate: require 'templates/editor/achievement/table'
projection: ['name', 'description', 'collection', 'slug']
getRenderData: ->
context = super()
context.currentEditor = 'editor.achievement_title'
context.currentNew = 'editor.new_achievement_title'
context.currentNewSignup = 'editor.new_achievement_title_login'
context.currentSearch = 'editor.achievement_search_title'
context.unauthorized = true unless me.isAdmin()
@$el.i18n()
context

View file

@ -6,6 +6,9 @@ CocoView = require './CocoView'
{logoutUser, me} = require('lib/auth')
locale = require 'locale/locale'
Achievement = require '../../models/Achievement'
User = require '../../models/User'
filterKeyboardEvents = (allowedEvents, func) ->
return (splat...) ->
e = splat[0]
@ -19,6 +22,71 @@ module.exports = class RootView extends CocoView
'click .toggle-fullscreen': 'toggleFullscreen'
'click .auth-button': 'onClickAuthbutton'
subscriptions:
'achievements:new': 'handleNewAchievements'
initialize: ->
$ =>
# TODO Ruben remove this. Allows for easy testing right now though
#test = new Achievement(_id:'537ce4855c91b8d1dda7fda8')
#test.fetch(success:@showNewAchievement)
showNewAchievement: (achievement) ->
currentLevel = me.level()
nextLevel = currentLevel + 1
currentLevelExp = User.expForLevel(currentLevel)
nextLevelExp = User.expForLevel(nextLevel)
totalExpNeeded = nextLevelExp - currentLevelExp
currentExp = me.get('points')
worth = achievement.get('worth')
leveledUp = currentExp - worth < currentLevelExp
alreadyAchievedPercentage = 100 * (currentExp - currentLevelExp - worth) / totalExpNeeded
newlyAchievedPercentage = if currentLevelExp is currentExp then 0 else 100 * worth / totalExpNeeded
console.debug "Current level is #{currentLevel} (#{currentLevelExp} xp), next level is #{nextLevel} (#{nextLevelExp} xp)."
console.debug "Need a total of #{nextLevelExp - currentLevelExp}, already had #{currentExp - currentLevelExp - worth} and just now earned #{worth} totalling on #{currentExp}"
alreadyAchievedBar = $("<div class='progress-bar progress-bar-warning' style='width:#{alreadyAchievedPercentage}%'></div>")
newlyAchievedBar = $("<div data-toggle='tooltip' class='progress-bar progress-bar-success' style='width:#{newlyAchievedPercentage}%'></div>")
emptyBar = $("<div data-toggle='tooltip' class='progress-bar progress-bar-white' style='width:#{100 - newlyAchievedPercentage - alreadyAchievedPercentage}%'></div>")
progressBar = $('<div class="progress" data-toggle="tooltip"></div>').append(alreadyAchievedBar).append(newlyAchievedBar).append(emptyBar)
message = if (currentLevel isnt 1) and leveledUp then "Reached level #{currentLevel}!" else null
alreadyAchievedBar.tooltip(title: "#{currentExp} XP in total")
newlyAchievedBar.tooltip(title: "#{worth} XP earned")
emptyBar.tooltip(title: "#{nextLevelExp - currentExp} XP until level #{nextLevel}")
# TODO a default should be linked here
imageURL = '/file/' + achievement.get('icon')
data =
title: achievement.get('name')
image: $("<img src='#{imageURL}' />")
description: achievement.get('description')
progressBar: progressBar
earnedExp: "+ #{worth} XP"
message: message
options =
autoHideDelay: 10000
globalPosition: 'bottom right'
showDuration: 400
style: 'achievement'
autoHide: true
clickToHide: true
$.notify( data, options )
handleNewAchievements: (earnedAchievements) ->
console.debug 'Got new earned achievements'
# TODO performance?
_.each(earnedAchievements.models, (earnedAchievement) =>
achievement = new Achievement(_id: earnedAchievement.get('achievement'))
console.log achievement
achievement.fetch(
success: @showNewAchievement
)
)
logoutAccount: ->
logoutUser($('#login-email').val())

View file

@ -4,8 +4,12 @@ forms = require('lib/forms')
app = require('application')
class SearchCollection extends Backbone.Collection
initialize: (modelURL, @model, @term) ->
@url = "#{modelURL}/search?project=true"
initialize: (modelURL, @model, @term, @projection) ->
@url = "#{modelURL}/search?project="
if @projection? and not (@projection == [])
@url += projection[0]
@url += ',' + projected for projected in projection[1..]
else @url += "true"
@url += "&term=#{term}" if @term
module.exports = class SearchView extends View
@ -17,6 +21,7 @@ module.exports = class SearchView extends View
model: null # Article
modelURL: null # '/db/article'
tableTemplate: null # require 'templates/editor/article/table'
projected: null # ['name', 'description', 'version'] or null for default
events:
'change input#search': 'runSearch'
@ -26,6 +31,7 @@ module.exports = class SearchView extends View
'shown.bs.modal #new-model-modal': 'focusOnName'
'hidden.bs.modal #new-model-modal': 'onModalHidden'
constructor: (options) ->
@runSearch = _.debounce(@runSearch, 500)
super options
@ -44,7 +50,7 @@ module.exports = class SearchView extends View
return if @sameSearch(term)
@removeOldSearch()
@collection = new SearchCollection(@modelURL, @model, term)
@collection = new SearchCollection(@modelURL, @model, term, @projection)
@collection.term = term # needed?
@listenTo(@collection, 'sync', @onSearchChange)
@showLoading(@$el.find('.results'))

View file

@ -0,0 +1,35 @@
mongoose = require('mongoose')
plugins = require('../plugins/plugins')
jsonschema = require('../../app/schemas/models/achievement')
log = require 'winston'
# `pre` and `post` are not called for update operations executed directly on the database,
# including `Model.update`,`.findByIdAndUpdate`,`.findOneAndUpdate`, `.findOneAndRemove`,and `.findByIdAndRemove`.order
# to utilize `pre` or `post` middleware, you should `find()` the document, and call the `init`, `validate`, `save`,
# or `remove` functions on the document. See [explanation](http://github.com/LearnBoost/mongoose/issues/964).
AchievementSchema = new mongoose.Schema({
userField: String
}, {strict: false})
AchievementSchema.methods.objectifyQuery = () ->
try
@set('query', JSON.parse(@get('query'))) if typeof @get('query') == "string"
catch error
log.error "Couldn't convert query string to object because of #{error}"
@set('query', {})
AchievementSchema.methods.stringifyQuery = () ->
@set('query', JSON.stringify(@get('query'))) if typeof @get('query') != "string"
AchievementSchema.post('init', (doc) -> doc.objectifyQuery())
AchievementSchema.pre('save', (next) ->
@stringifyQuery()
next()
)
AchievementSchema.plugin(plugins.NamedPlugin)
AchievementSchema.plugin(plugins.SearchablePlugin, {searchable: ['name']})
module.exports = Achievement = mongoose.model('Achievement', AchievementSchema)

View file

@ -0,0 +1,19 @@
mongoose = require 'mongoose'
jsonschema = require '../../app/schemas/models/earned_achievement'
EarnedAchievementSchema = new mongoose.Schema({
created:
type: Date
default: Date.now
changed:
type: Date
default: Date.now
notified:
type: Boolean
default: false
}, {strict:false})
EarnedAchievementSchema.index({user: 1, achievement: 1}, {unique: true, name: 'earned achievement index'})
EarnedAchievementSchema.index({user: 1, changed: -1}, {name: 'latest '})
module.exports = EarnedAchievement = mongoose.model('EarnedAchievement', EarnedAchievementSchema)

View file

@ -0,0 +1,21 @@
Achievement = require './Achievement'
Handler = require '../commons/Handler'
class AchievementHandler extends Handler
modelClass: Achievement
# Used to determine which properties requests may edit
editableProperties: ['name', 'query', 'worth', 'collection', 'description', 'userField', 'proportionalTo', 'icon']
jsonSchema = require '../../app/schemas/models/achievement.coffee'
hasAccess: (req) ->
req.method is 'GET' or req.user?.isAdmin()
get: (req, res) ->
query = @modelClass.find({})
query.exec (err, documents) =>
return @sendDatabaseError(res, err) if err
documents = (@formatEntity(req, doc) for doc in documents)
@sendSuccess(res, documents)
module.exports = new AchievementHandler()

View file

@ -0,0 +1,12 @@
mongoose = require('mongoose')
EarnedAchievement = require './EarnedAchievement'
Handler = require '../commons/Handler'
class EarnedAchievementHandler extends Handler
modelClass: EarnedAchievement
# Don't allow POSTs or anything yet
hasAccess: (req) ->
req.method is 'GET'
module.exports = new EarnedAchievementHandler()

View file

@ -153,13 +153,13 @@ module.exports = class Handler
return @sendDatabaseError(res, err) if err
@sendSuccess(res, @formatEntity(req, document))
# project=true or project=name,description,slug for example
search: (req, res) ->
unless @modelClass.schema.uses_coco_search
return @sendNotFoundError(res)
term = req.query.term
matchedObjects = []
filters = [{filter: {index: true}}]
filters = if @modelClass.schema.uses_coco_versions or @modelClass.schema.uses_coco_permissions then [filter: {index: true}] else [filter: {}]
if @modelClass.schema.uses_coco_permissions and req.user
filters.push {filter: {index: req.user.get('id')}}
projection = null
@ -167,7 +167,7 @@ module.exports = class Handler
projection = PROJECT
else if req.query.project
if @modelClass.className is 'User'
projection = PROJECTION
projection = PROJECT
log.warn "Whoa, we haven't yet thought about public properties for User projection yet."
else
projection = {}

View file

@ -9,6 +9,8 @@ module.exports.handlers =
'patch': 'patches/patch_handler'
'thang_type': 'levels/thangs/thang_type_handler'
'user': 'users/user_handler'
'achievement': 'achievements/achievement_handler'
'earned_achievement': 'achievements/earned_achievement_handler'
module.exports.routes =
[

View file

@ -2,6 +2,7 @@
mongoose = require('mongoose')
plugins = require('../../plugins/plugins')
AchievablePlugin = require '../../plugins/achievements'
jsonschema = require('../../../app/schemas/models/level_session')
LevelSessionSchema = new mongoose.Schema({
@ -10,6 +11,7 @@ LevelSessionSchema = new mongoose.Schema({
'default': Date.now
}, {strict: false})
LevelSessionSchema.plugin(plugins.PermissionsPlugin)
LevelSessionSchema.plugin(AchievablePlugin)
LevelSessionSchema.pre 'init', (next) ->
# TODO: refactor this into a set of common plugins for all models?

View file

@ -0,0 +1,89 @@
mongoose = require('mongoose')
Achievement = require('../achievements/Achievement')
EarnedAchievement = require '../achievements/EarnedAchievement'
User = require '../users/User'
LocalMongo = require '../../app/lib/LocalMongo'
util = require '../../app/lib/utils'
log = require 'winston'
achievements = {}
loadAchievements = ->
achievements = {}
query = Achievement.find({})
query.exec (err, docs) ->
_.each docs, (achievement) ->
category = achievement.get 'collection'
achievements[category] = [] unless category of achievements
achievements[category].push achievement
loadAchievements()
module.exports = AchievablePlugin = (schema, options) ->
checkForAchievement = (doc) ->
collectionName = doc.constructor.modelName
before = {}
schema.post 'init', (doc) ->
before[doc.id] = doc.toObject()
schema.post 'save', (doc) ->
isNew = not doc.isInit('_id')
originalDocObj = before[doc.id] unless isNew
category = doc.constructor.modelName
if category of achievements
docObj = doc.toObject()
for achievement in achievements[category]
query = achievement.get('query')
isRepeatable = achievement.get('proportionalTo')?
alreadyAchieved = if isNew then false else LocalMongo.matchesQuery originalDocObj, query
newlyAchieved = LocalMongo.matchesQuery(docObj, query)
log.debug 'isRepeatable: ' + isRepeatable
log.debug 'alreadyAchieved: ' + alreadyAchieved
log.debug 'newlyAchieved: ' + newlyAchieved
userObjectID = doc.get(achievement.get('userField'))
userID = if _.isObject userObjectID then userObjectID.toHexString() else userObjectID # Standardize! Use strings, not ObjectId's
if newlyAchieved and (not alreadyAchieved or isRepeatable)
earned = {
user: userID
achievement: achievement._id.toHexString()
achievementName: achievement.get 'name'
}
earnedPoints = 0
wrapUp = ->
# Update user's experience points
User.update({_id: userID}, {$inc: {points: earnedPoints}}, {}, (err, count) ->
console.error err if err?
)
if isRepeatable
log.debug 'Upserting repeatable achievement called \'' + (achievement.get 'name') + '\' for ' + userID
proportionalTo = achievement.get 'proportionalTo'
originalAmount = util.getByPath(originalDocObj, proportionalTo) or 0
newAmount = docObj[proportionalTo]
if originalAmount isnt newAmount
earned.notified = false
earned.achievedAmount = newAmount
earned.changed = Date.now()
EarnedAchievement.findOneAndUpdate({achievement:earned.achievement, user:earned.user}, earned, upsert:true, (err, docs) ->
return log.debug err if err?
)
earnedPoints = achievement.get('worth') * (newAmount - originalAmount)
wrapUp()
else # not alreadyAchieved
log.debug 'Creating a new earned achievement called \'' + (achievement.get 'name') + '\' for ' + userID
(new EarnedAchievement(earned)).save (err, doc) ->
return log.debug err if err?
earnedPoints = achievement.get('worth')
wrapUp()
delete before[doc.id] unless isNew # This assumes everything we patch has a _id
return

View file

@ -127,3 +127,6 @@ UserSchema.statics.hashPassword = (password) ->
shasum.digest('hex')
module.exports = User = mongoose.model('User', UserSchema)
AchievablePlugin = require '../plugins/achievements'
UserSchema.plugin(AchievablePlugin)

View file

@ -10,6 +10,7 @@ async = require 'async'
log = require 'winston'
LevelSession = require('../levels/sessions/LevelSession')
LevelSessionHandler = require '../levels/sessions/level_session_handler'
EarnedAchievement = require '../achievements/EarnedAchievement'
serverProperties = ['passwordHash', 'emailLower', 'nameLower', 'passwordReset']
privateProperties = [
@ -192,6 +193,7 @@ UserHandler = class UserHandler extends Handler
return @getCandidates(req, res) if args[1] is 'candidates'
return @getSimulatorLeaderboard(req, res, args[0]) if args[1] is 'simulatorLeaderboard'
return @getMySimulatorLeaderboardRank(req, res, args[0]) if args[1] is 'simulator_leaderboard_rank'
return @getEarnedAchievements(req, res, args[0]) if args[1] is 'achievements'
return @sendNotFoundError(res)
super(arguments...)
@ -235,6 +237,18 @@ UserHandler = class UserHandler extends Handler
documents = (LevelSessionHandler.formatEntity(req, doc) for doc in documents)
@sendSuccess(res, documents)
getEarnedAchievements: (req, res, userID) ->
queryObject = {$query: {user: userID}, $orderby: {changed: -1}}
queryObject.$query.notified = false if req.query.notified is 'false'
query = EarnedAchievement.find(queryObject)
query.exec (err, documents) =>
return @sendDatabaseError(res, err) if err?
cleandocs = (@formatEntity(req, doc) for doc in documents)
for doc in documents # Maybe move this logic elsewhere
doc.set('notified', true)
doc.save()
@sendSuccess(res, cleandocs)
agreeToEmployerAgreement: (req, res) ->
userIsAnonymous = req.user?.get('anonymous')
if userIsAnonymous then return errors.unauthorized(res, "You need to be logged in to agree to the employer agreeement.")

View file

@ -0,0 +1,77 @@
describe 'Local Mongo queries', ->
LocalMongo = require 'lib/LocalMongo'
beforeEach ->
this.fixture1 =
'id': 'somestring'
'value': 9000
'levels': [3, 8, 21]
'worth': 6
'type': 'unicorn'
'likes': ['poptarts', 'popsicles', 'popcorn']
this.fixture2 = this: is: so: 'deep'
it 'regular match of a property', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'gender': 'unicorn')).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'type':'unicorn')).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'type':'zebra')).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'type':'unicorn', 'id':'somestring')).toBeTruthy()
it 'array match of a property', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'likes':'poptarts')).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'likes':'walks on the beach')).toBeFalsy()
it 'nested match', ->
expect(LocalMongo.matchesQuery(this.fixture2, 'this.is.so':'deep')).toBeTruthy()
it '$gt selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gt': 8000)).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gt': [8000, 10000])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'levels': '$gt': [10, 20, 30])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gt': 9000)).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': {'$gt': 8000}, 'worth': {'$gt': 5})).toBeTruthy()
it '$gte selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gte': 9001)).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gte': 9000)).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$gte': [9000, 10000])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'levels': '$gte': [21, 30])).toBeTruthy()
it '$lt selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$lt': 9001)).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$lt': 9000)).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$lt': [9001, 9000])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'levels': '$lt': [10, 20, 30])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': {'$lt': 9001}, 'worth': {'$lt': 7})).toBeTruthy()
it '$lte selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$lte': 9000)).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$lte': 8000)).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'value': {'$lte': 9000}, 'worth': {'$lte': [6, 5]})).toBeTruthy()
it '$ne selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'value': '$ne': 9000)).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'id': '$ne': 'otherstring')).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'id': '$ne': ['otherstring', 'somestring'])).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'likes': '$ne': ['popcorn', 'chicken'])).toBeFalsy()
it '$in selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'type': '$in': ['unicorn', 'zebra'])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'type': '$in': ['cats', 'dogs'])).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'likes': '$in': ['popcorn', 'chicken'])).toBeTruthy()
it '$nin selector', ->
expect(LocalMongo.matchesQuery(this.fixture1, 'type': '$nin': ['unicorn', 'zebra'])).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, 'type': '$nin': ['cats', 'dogs'])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, 'likes': '$nin': ['popcorn', 'chicken'])).toBeFalsy()
it '$or operator', ->
expect(LocalMongo.matchesQuery(this.fixture1, $or: [{value:9000}, {type:'zebra'}])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, $or: [{value:9001}, {worth:$lt:10}])).toBeTruthy()
it '$and operator', ->
expect(LocalMongo.matchesQuery(this.fixture1, $and: [{value:9000}, {type:'zebra'}])).toBeFalsy()
expect(LocalMongo.matchesQuery(this.fixture1, $and: [{value:9000}, {type:'unicorn'}])).toBeTruthy()
expect(LocalMongo.matchesQuery(this.fixture1, $and: [{value:$gte:9000}, {worth:$lt:10}])).toBeTruthy()