Generalize new I18N view system

Previously, when diplomats submit translations, the system
would try to figure out whether it should be a 'patch' or a 'change',
and then would either create a patch for an admin or artisan to
review and accept or reject, or would apply the changes immediately
and they would be live. This was done as a compromise between
getting translations live quickly, but also preventing already-translated
text from getting overwritten without oversight.

But having the client handle this added logical complexity. So
this makes all diplomats submit patches, no matter what. The server
is then in charge of deciding if it should auto-accept the patch or not.
Either way, a patch is created.

There was also much refactoring. This commit includes:

* Update jsondiffpatch so changes within array items are handled correctly
* Refactor posting patches to use the new auto-accepting logic, and out of Patch model
* Refactor POST /db/patch/:handle/status so that it doesn't rely on handlers
* Refactor patch stat handling to ensure auto-accepted patches are counted
* Refactor User.incrementStat to use mongodb update commands, to avoid race conditions
* Refactor Patch tests
This commit is contained in:
Scott Erickson 2016-09-07 16:15:54 -07:00
parent e70556e900
commit 7bab895dee
29 changed files with 860 additions and 636 deletions

View file

@ -89,8 +89,8 @@ expandFlattenedDelta = (delta, left, schema) ->
delta
module.exports.makeJSONDiffer = ->
hasher = (obj) -> if obj? then obj.name or obj.id or obj._id or JSON.stringify(_.keys(obj)) else 'null'
jsondiffpatch.create({objectHash: hasher})
objectHash = (obj) -> if obj? then (obj.name or obj.id or obj._id or JSON.stringify(_.keys(obj))) else 'null'
jsondiffpatch.create({objectHash})
module.exports.getConflicts = (headDeltas, pendingDeltas) ->
# headDeltas and pendingDeltas should be lists of deltas returned by expandDelta

View file

@ -5,8 +5,10 @@ module.exports = class PatchModel extends CocoModel
@schema: require 'schemas/models/patch'
urlRoot: '/db/patch'
setStatus: (status) ->
PatchModel.setStatus @id, status
setStatus: (status, options={}) ->
options.url = "/db/patch/#{@id}/status"
options.type = 'PUT'
@save({status}, options)
@setStatus: (id, status) ->
$.ajax("/db/patch/#{id}/status", {type: 'PUT', data: {status: status}})

View file

@ -29,16 +29,6 @@ block header
span.glyphicon-chevron-down.glyphicon
ul.dropdown-menu
li.dropdown-header(data-i18n="common.actions") Actions
li(class=anonymous ? "disabled": "")
a(data-toggle="coco-modal", data-target="modal/RevertModal", data-i18n="editor.revert")#revert-button Revert
li.divider
li.dropdown-header(data-i18n="common.info") Info
li#history-button
a(href='#', data-i18n="general.version_history") Version History
li.divider
li.dropdown-header(data-i18n="common.help") Help
li
a(href='https://github.com/codecombat/codecombat/wiki', data-i18n="editor.wiki", target="_blank") Wiki

View file

@ -11,21 +11,6 @@ module.exports = class I18NEditCourseView extends I18NEditModelView
id: "i18n-edit-course-view"
modelClass: Course
events:
'click .open-patch-link': 'onClickOpenPatchLink'
constructor: ->
super(arguments...)
@model.saveBackups = false
@madeChanges = false
@patches = new Patches()
@patches.comparator = '_id'
@supermodel.trackRequest(@patches.fetchMineFor(@model))
onLoaded: ->
super(arguments...)
@originalModel = @model.clone()
buildTranslationList: ->
lang = @selectedLanguage
@ -36,56 +21,3 @@ module.exports = class I18NEditCourseView extends I18NEditModelView
if description = @model.get('description')
@wrapRow 'Course description', ['description'], description, i18n[lang]?.description, []
onTranslationChanged: ->
super(arguments...)
@madeChanges = true
onClickOpenPatchLink: (e) ->
patchID = $(e.currentTarget).data('patch-id')
patch = @patches.get(patchID)
modal = new PatchModal(patch, @model)
@openModalView(modal)
onLeaveMessage: ->
if @madeChanges
return 'You have unsaved changes!'
onLanguageSelectChanged: ->
if @madeChanges
return unless confirm('You have unsaved changes!')
super(arguments...)
@madeChanges = false
@model.set(@originalModel.clone().attributes)
onSubmitPatch: (e) ->
delta = @model.getDelta()
flattened = deltasLib.flattenDelta(delta)
patch = new Patch({
delta
target: {
'collection': _.string.underscored @model.constructor.className
'id': @model.id
}
commitMessage: "Diplomat submission for lang #{@selectedLanguage}: #{flattened.length} change(s)."
})
errors = patch.validate()
button = $(e.target)
button.attr('disabled', 'disabled')
return button.text('Failed to Submit Changes') if errors
res = patch.save(null, {
url: "/db/course/#{@model.id}/patch"
})
return button.text('Failed to Submit Changes') unless res
button.text('Submitting...')
Promise.resolve(res)
.then =>
@savedBefore = true
@madeChanges = false
@patches.add(patch)
@renderSelectors('#patches-col')
button.text('Submit Changes')
.catch =>
button.text('Error Submitting Changes')

View file

@ -1,27 +1,42 @@
RootView = require 'views/core/RootView'
locale = require 'locale/locale'
Patch = require 'models/Patch'
Patches = require 'collections/Patches'
PatchModal = require 'views/editor/PatchModal'
template = require 'templates/i18n/i18n-edit-model-view'
deltasLib = require 'core/deltas'
# in the template, but need to require to load them
require 'views/modal/RevertModal'
###
This view is the superclass for all views which Diplomats use to submit translations
for database documents. They all work mostly the same, except they each set their
`@modelClass` which is a patchable Backbone model class, and they use `@wrapRow()`
to dynamically specify which properties are being translated.
###
UNSAVED_CHANGES_MESSAGE = 'You have unsaved changes! Really discard them?'
module.exports = class I18NEditModelView extends RootView
className: 'editor i18n-edit-model-view'
template: template
events:
'change .translation-input': 'onInputChanged'
'input .translation-input': 'onInputChanged'
'change #language-select': 'onLanguageSelectChanged'
'click #patch-submit': 'onSubmitPatch'
'click .open-patch-link': 'onClickOpenPatchLink'
constructor: (options, @modelHandle) ->
super(options)
@model = new @modelClass(_id: @modelHandle)
@model = @supermodel.loadModel(@model).model
@model.saveBackups = true
@supermodel.trackRequest(@model.fetch())
@patches = new Patches()
@listenTo @patches, 'change', -> @renderSelectors('#patches-col')
@patches.comparator = '_id'
@supermodel.trackRequest(@patches.fetchMineFor(@model))
@selectedLanguage = me.get('preferredLanguage', true)
@madeChanges = false
showLoading: ($el) ->
$el ?= @$el.find('.outer-content')
@ -29,7 +44,7 @@ module.exports = class I18NEditModelView extends RootView
onLoaded: ->
super()
@model.markToRevert() unless @model.hasLocalChanges()
@originalModel = @model.clone()
getRenderData: ->
c = super()
@ -47,11 +62,11 @@ module.exports = class I18NEditModelView extends RootView
afterRender: ->
super()
@hush = true
@ignoreLanguageSelectChanges = true
$select = @$el.find('#language-select').empty()
@addLanguagesToSelect($select, @selectedLanguage)
@$el.find('option[value="en-US"]').remove()
@hush = false
@ignoreLanguageSelectChanges = false
editors = []
@$el.find('tr[data-format="markdown"]').each((index, el) =>
@ -103,7 +118,6 @@ module.exports = class I18NEditModelView extends RootView
@onTranslationChanged(rowInfo, value)
onTranslationChanged: (rowInfo, value) ->
#- Navigate down to where the translation will live
base = @model.attributes
@ -121,61 +135,59 @@ module.exports = class I18NEditModelView extends RootView
base = base[seg]
#- Set the data in a non-kosher way
base[rowInfo.key[rowInfo.key.length-1]] = value
@model.saveBackup()
#- Enable patch submit button
@$el.find('#patch-submit').attr('disabled', null)
@madeChanges = true
onLanguageSelectChanged: (e) ->
return if @hush
return if @ignoreLanguageSelectChanges
if @madeChanges
return unless confirm(UNSAVED_CHANGES_MESSAGE)
@selectedLanguage = $(e.target).val()
if @selectedLanguage
me.set('preferredLanguage', @selectedLanguage)
me.patch()
@madeChanges = false
@model.set(@originalModel.clone().attributes)
@render()
onClickOpenPatchLink: (e) ->
patchID = $(e.currentTarget).data('patch-id')
patch = @patches.get(patchID)
modal = new PatchModal(patch, @model)
@openModalView(modal)
onLeaveMessage: ->
if @madeChanges
return UNSAVED_CHANGES_MESSAGE
onSubmitPatch: (e) ->
delta = @model.getDelta()
delta = @originalModel.getDeltaWith(@model)
flattened = deltasLib.flattenDelta(delta)
save = _.all(flattened, (delta) ->
return _.isArray(delta.o) and delta.o.length is 1 and 'i18n' in delta.dataPath
)
commitMessage = "Diplomat submission for lang #{@selectedLanguage}: #{flattened.length} change(s)."
save = false if @savedBefore
if save
modelToSave = @model.cloneNewMinorVersion()
modelToSave.updateI18NCoverage() if modelToSave.get('i18nCoverage')
if @modelClass.schema.properties.commitMessage
modelToSave.set 'commitMessage', commitMessage
else
modelToSave = new Patch()
modelToSave.set 'delta', @model.getDelta()
modelToSave.set 'target', {
'collection': _.string.underscored @model.constructor.className
'id': @model.id
}
modelToSave.set 'commitMessage', commitMessage
errors = modelToSave.validate()
collection = _.string.underscored @model.constructor.className
patch = new Patch({
delta
target: { collection, 'id': @model.id }
commitMessage: "Diplomat submission for lang #{@selectedLanguage}: #{flattened.length} change(s)."
})
errors = patch.validate()
button = $(e.target)
button.attr('disabled', 'disabled')
return button.text('No changes submitted, did not save patch.') unless delta
return button.text('Failed to Submit Changes') if errors
type = 'PUT'
if @modelClass.schema.properties.version or (not save)
# Override PUT so we can trigger postNewVersion logic
# or you're POSTing a Patch
type = 'POST'
res = modelToSave.save(null, {type: type})
res = patch.save(null, { url: _.result(@model, 'url') + '/patch' })
return button.text('Failed to Submit Changes') unless res
button.text('Submitting...')
res.error => button.text('Error Submitting Changes')
res.success =>
@savedBefore = true
Promise.resolve(res)
.then =>
@madeChanges = false
@patches.add(patch)
@renderSelectors('#patches-col')
button.text('Submit Changes')
.catch =>
button.text('Error Submitting Changes')
@$el.find('#patch-submit').attr('disabled', null)

View file

@ -35,7 +35,7 @@
"aether": "~0.5.21",
"underscore.string": "~2.3.3",
"d3": "~3.4.4",
"jsondiffpatch": "0.1.8",
"jsondiffpatch": "^0.2.3",
"nanoscroller": "~0.8.0",
"treema": "https://github.com/codecombat/treema.git#master",
"bootstrap": "~3.2.0",
@ -72,9 +72,9 @@
},
"jsondiffpatch": {
"main": [
"build/bundle-full.js",
"build/formatters.js",
"src/formatters/html.css"
"public/build/jsondiffpatch-full.js",
"public/build/jsondiffpatch-formatters.js",
"public/formatters-styles/html.css"
]
},
"treema": {

View file

@ -69,7 +69,7 @@
"geoip-lite": "^1.1.6",
"graceful-fs": "~2.0.1",
"gridfs-stream": "~1.1.1",
"jsondiffpatch": "0.1.17",
"jsondiffpatch": "^0.2.3",
"lodash": "~2.4.1",
"lz-string": "^1.3.3",
"mailchimp-api": "2.0.x",

View file

@ -0,0 +1,36 @@
slack = require '../slack'
sendwithus = require '../sendwithus'
User = require '../models/User'
# TODO: Refactor notification (slack, watcher emails) logic here
module.exports =
notifyChangesMadeToDoc: (req, doc) ->
# TODO: Stop using headers to pass edit paths. Perhaps should be a method property for Mongoose models
editPath = req.headers['x-current-path']
docLink = "http://codecombat.com#{editPath}"
# Post a message on Slack
message = "#{req.user.get('name')} saved a change to #{doc.get('name')}: #{doc.get('commitMessage') or '(no commit message)'} #{docLink}"
slack.sendSlackMessage message, ['artisans']
# Send emails to watchers
watchers = doc.get('watchers') or []
# Don't send these emails to the person who submitted the patch, or to Nick, George, or Scott.
watchers = (w for w in watchers when not w.equals(req.user._id) and not (w.toHexString() in ['512ef4805a67a8c507000001', '5162fab9c92b4c751e000274', '51538fdb812dd9af02000001']))
if watchers.length
User.find({_id:{$in:watchers}}).select({email:1, name:1}).exec (err, watchers) ->
for watcher in watchers
continue if not watcher.get('email')
context =
email_id: sendwithus.templates.change_made_notify_watcher
recipient:
address: watcher.get('email')
name: watcher.get('name')
email_data:
doc_name: doc.get('name') or '???'
submitter_name: req.user.get('name') or '???'
doc_link: if editPath then docLink else null
commit_message: doc.get('commitMessage')
sendwithus.api.send context, _.noop

View file

@ -1,23 +0,0 @@
mongoose = require 'mongoose'
Handler = require '../commons//Handler'
Course = require '../models/Course'
# TODO: Refactor PatchHandler.setStatus into its own route.
# This handler has been resurrected solely so that course patches can be accepted.
CourseHandler = class CourseHandler extends Handler
modelClass: Course
jsonSchema: require '../../app/schemas/models/course.schema'
allowedMethods: []
hasAccess: (req) ->
req.method in @allowedMethods or req.user?.isAdmin()
hasAccessToDocument: (req, document, method=null) ->
method = (method or req.method).toLowerCase()
return true if method is 'get'
return true if req.user?.isAdmin() or req.user?.isArtisan()
return
module.exports = new CourseHandler()

View file

@ -21,10 +21,6 @@ PatchHandler = class PatchHandler extends Handler
patch.set 'status', 'pending'
patch
getByRelationship: (req, res, args...) ->
return @setStatus(req, res, args[0]) if req.route.method is 'put' and args[1] is 'status'
super(arguments...)
get: (req, res) ->
if req.query.view in ['pending']
query = status: 'pending'
@ -36,54 +32,6 @@ PatchHandler = class PatchHandler extends Handler
else
super(arguments...)
setStatus: (req, res, id) ->
newStatus = req.body.status
unless newStatus in ['rejected', 'accepted', 'withdrawn']
return @sendBadInputError(res, 'Status must be "rejected", "accepted", or "withdrawn"')
@getDocumentForIdOrSlug id, (err, patch) =>
return @sendDatabaseError(res, err) if err
return @sendNotFoundError(res) unless patch?
targetInfo = patch.get('target')
targetHandler = require('../' + handlers[targetInfo.collection])
targetModel = targetHandler.modelClass
query = { $or: [{'original': targetInfo.original}, {'_id': mongoose.Types.ObjectId(targetInfo.original)}] }
sort = { 'version.major': -1, 'version.minor': -1 }
targetModel.findOne(query).sort(sort).exec (err, target) =>
return @sendDatabaseError(res, err) if err
return @sendNotFoundError(res) unless target?
return @sendForbiddenError(res) unless targetHandler.hasAccessToDocument(req, target, 'get')
if newStatus in ['rejected', 'accepted']
return @sendForbiddenError(res) unless targetHandler.hasAccessToDocument(req, target, 'put')
if newStatus is 'withdrawn'
return @sendForbiddenError(res) unless req.user.get('_id').equals patch.get('creator')
patch.set 'status', newStatus
# Only increment statistics upon very first accept
if patch.isNewlyAccepted()
patch.set 'acceptor', req.user.get('id')
acceptor = req.user.get 'id'
submitter = patch.get 'creator'
User.incrementStat acceptor, 'stats.patchesAccepted'
# TODO maybe merge these increments together
if patch.isTranslationPatch()
User.incrementStat submitter, 'stats.totalTranslationPatches'
User.incrementStat submitter, User.statsMapping.translations[targetModel.modelName]
if patch.isMiscPatch()
User.incrementStat submitter, 'stats.totalMiscPatches'
User.incrementStat submitter, User.statsMapping.misc[targetModel.modelName]
# these require callbacks
patch.save (err) =>
log.error err if err?
target.update {$pull:{patches:patch.get('_id')}}, {}, ->
@sendSuccess(res, null)
onPostSuccess: (req, doc) ->
log.error 'Error sending patch created: could not find the loaded target on the patch object.' unless doc.targetLoaded
return unless doc.targetLoaded

View file

@ -10,11 +10,6 @@ Course = require '../models/Course'
User = require '../models/User'
Level = require '../models/Level'
parse = require '../commons/parse'
Patch = require '../models/Patch'
tv4 = require('tv4').tv4
slack = require '../slack'
{ isJustFillingTranslations } = require '../commons/deltas'
{ updateI18NCoverage } = require '../commons/i18n'
module.exports =
@ -87,61 +82,3 @@ module.exports =
results = yield database.viewSearch(dbq, req)
results = Course.sortCourses results
res.send(results)
postPatch: wrap (req, res) ->
# TODO: Generalize this and use for other models, once this has been put through its paces
course = yield database.getDocFromHandle(req, Course)
if not course
throw new errors.NotFound('Course not found.')
originalDelta = req.body.delta
originalCourse = course.toObject()
changedCourse = _.cloneDeep(course.toObject(), (value) ->
return value if value instanceof mongoose.Types.ObjectId
return value if value instanceof Date
return undefined
)
jsondiffpatch.patch(changedCourse, originalDelta)
# normalize the delta because in tests, changes to patches would sneak in and cause false positives
# TODO: Figure out a better system. Perhaps submit a series of paths? I18N Edit Views already use them for their rows.
normalizedDelta = jsondiffpatch.diff(originalCourse, changedCourse)
normalizedDelta = _.pick(normalizedDelta, _.keys(originalDelta))
reasonNotAutoAccepted = undefined
validation = tv4.validateMultiple(changedCourse, Course.jsonSchema)
if not validation.valid
reasonNotAutoAccepted = 'Did not pass json schema.'
else if not isJustFillingTranslations(normalizedDelta)
reasonNotAutoAccepted = 'Adding to existing translations.'
else
course.set(changedCourse)
updateI18NCoverage(course)
yield course.save()
patch = new Patch(req.body)
patch.set({
target: {
collection: 'course'
id: course._id
original: course._id
}
creator: req.user._id
status: if reasonNotAutoAccepted then 'pending' else 'accepted'
created: new Date().toISOString()
reasonNotAutoAccepted: reasonNotAutoAccepted
})
database.validateDoc(patch)
if reasonNotAutoAccepted
yield course.update({ $addToSet: { patches: patch._id }})
patches = course.get('patches') or []
patches.push patch._id
course.set({patches})
yield patch.save()
res.status(201).send(patch.toObject({req: req}))
docLink = "https://codecombat.com/editor/course/#{course.id}"
message = "#{req.user.get('name')} submitted a patch to #{course.get('name')}: #{patch.get('commitMessage')} #{docLink}"
slack.sendSlackMessage message, ['artisans']

View file

@ -13,6 +13,7 @@ module.exports =
levels: require './levels'
named: require './named'
patchable: require './patchable'
patches: require './patches'
prepaids: require './prepaids'
rest: require './rest'
trialRequests: require './trial-requests'

View file

@ -3,11 +3,18 @@ errors = require '../commons/errors'
wrap = require 'co-express'
Promise = require 'bluebird'
Patch = require '../models/Patch'
User = require '../models/User'
mongoose = require 'mongoose'
database = require '../commons/database'
parse = require '../commons/parse'
slack = require '../slack'
{ isJustFillingTranslations } = require '../commons/deltas'
{ updateI18NCoverage } = require '../commons/i18n'
{ initNewVersion, saveNewVersion } = require '../middleware/versions'
{ notifyChangesMadeToDoc } = require '../commons/notify'
module.exports =
patches: (Model, options={}) -> wrap (req, res) ->
dbq = Patch.find()
dbq.limit(parse.getLimitFromReq(req))
@ -31,6 +38,7 @@ module.exports =
patches = yield dbq.find(query).sort('-created')
res.status(200).send(patches)
joinWatchers: (Model, options={}) -> wrap (req, res) ->
doc = yield database.getDocFromHandle(req, Model)
if not doc
@ -44,6 +52,7 @@ module.exports =
doc.set('watchers', watchers)
res.status(200).send(doc)
leaveWatchers: (Model, options={}) -> wrap (req, res) ->
doc = yield database.getDocFromHandle(req, Model)
if not doc
@ -54,3 +63,96 @@ module.exports =
watchers = _.filter watchers, (id) -> not id.equals(req.user._id)
doc.set('watchers', watchers)
res.status(200).send(doc)
postPatch: _.curry wrap (Model, collectionName, req, res) ->
# handle either "POST /db/<collection>/:handle/patch" or "POST /db/patch" with target included in body
if req.params.handle
target = yield database.getDocFromHandle(req, Model)
else if req.body.target?.id
target = yield Model.findById(req.body.target.id)
if not target
throw new errors.NotFound('Target not found.')
# normalize the delta because in tests, changes to patches would sneak in and cause false positives
# TODO: Figure out a better system. Perhaps submit a series of paths? I18N Edit Views already use them for their rows.
originalDelta = req.body.delta
originalTarget = target.toObject()
# _.cloneDeep can't handle ObjectIds, and be careful with Dates, too.
newTargetAttrs = _.cloneDeep(target.toObject(), (value) ->
return value if value instanceof mongoose.Types.ObjectId
return value if value instanceof Date
return undefined
)
jsondiffpatch.patch(newTargetAttrs, originalDelta)
normalizedDelta = jsondiffpatch.diff(originalTarget, newTargetAttrs)
normalizedDelta = _.pick(normalizedDelta, _.keys(originalDelta))
# decide whether the patch should be auto-accepted, or left 'pending' for an admin or artisan to review
reasonNotAutoAccepted = undefined
validation = tv4.validateMultiple(newTargetAttrs, Model.jsonSchema)
if not validation.valid
reasonNotAutoAccepted = 'Did not pass json schema.'
else if not isJustFillingTranslations(normalizedDelta)
reasonNotAutoAccepted = 'Adding to existing translations.'
else
# save changes directly to the target, whether versioned or not, and send out notifications
if Model.schema.uses_coco_versions
newVersion = database.initDoc(req, Model)
initNewVersion(newVersion, target)
newVersionAttrs = newVersion.toObject()
jsondiffpatch.patch(newVersionAttrs, normalizedDelta)
newVersion.set(newVersionAttrs)
newVersion.set('commitMessage', req.body.commitMessage)
major = target.get('version.major')
updateI18NCoverage(newVersion)
yield saveNewVersion(newVersion, major)
target = newVersion
else
target.set(newTargetAttrs)
updateI18NCoverage(target)
yield target.save()
notifyChangesMadeToDoc(req, target)
# create, validate and save the patch
if Model.schema.uses_coco_versions
patchTarget = {
collection: collectionName
id: target._id
original: target._id
version: _.pick(target.get('version'), 'major', 'minor')
}
else
patchTarget = {
collection: collectionName
id: target._id
original: target._id
}
patch = new Patch()
patch.set({
delta: normalizedDelta
commitMessage: req.body.commitMessage
target: patchTarget
creator: req.user._id
status: if reasonNotAutoAccepted then 'pending' else 'accepted'
created: new Date().toISOString()
reasonNotAutoAccepted: reasonNotAutoAccepted
})
database.validateDoc(patch)
# add this patch to the denormalized list of patches on the target
if reasonNotAutoAccepted
yield target.update({ $addToSet: { patches: patch._id }})
yield patch.save()
User.incrementStat req.user.id, 'stats.patchesSubmitted'
res.status(201).send(patch.toObject({req: req}))
if reasonNotAutoAccepted
docLink = "https://codecombat.com/editor/#{collectionName}/#{target.id}"
message = "#{req.user.get('name')} submitted a patch to #{target.get('name')}: #{patch.get('commitMessage')} #{docLink}"
slack.sendSlackMessage message, ['artisans']

View file

@ -0,0 +1,80 @@
utils = require '../lib/utils'
errors = require '../commons/errors'
wrap = require 'co-express'
database = require '../commons/database'
parse = require '../commons/parse'
{ postPatch } = require './patchable'
mongoose = require 'mongoose'
Patch = require '../models/Patch'
User = require '../models/User'
# TODO: Standardize model names so that this map is unnecessary
collectionNameMap = {
'achievement': 'Achievement'
'level_component': 'level.component'
'level_system': 'level.system'
'thang_type': 'thang.type'
}
module.exports.post = wrap (req, res) ->
# Based on body, figure out what collection and document this patch is for
if not req.body.target?.collection
throw new errors.UnprocessableEntity('target.collection not provided')
collection = req.body.target?.collection
modelName = collectionNameMap[collection] or collection
try
Model = mongoose.model(modelName)
catch e
if e.name is 'MissingSchemaError'
throw new errors.NotFound("#{collection} is not a known model")
else
throw e
if not Model.schema.is_patchable
throw new errors.UnprocessableEntity("#{collection} is not patchable")
# pass to logic shared with "POST /db/:collection/:handle/patch"
yield postPatch(Model, collection, req, res)
# Allow patch submitters to withdraw their patches, or admins/artisans to accept/reject others' patches
module.exports.setStatus = wrap (req, res) ->
newStatus = req.body.status or req.body
unless newStatus in ['rejected', 'accepted', 'withdrawn']
throw new errors.UnprocessableEntity('Status must be "rejected", "accepted", or "withdrawn"')
patch = yield database.getDocFromHandle(req, Patch)
if not patch
throw new errors.NotFound('Could not find patch')
# Find the target of the patch
collection = patch.get('target.collection')
modelName = collectionNameMap[collection] or collection
Model = mongoose.model(modelName)
original = patch.get('target.original')
query = { $or: [{original}, {'_id': mongoose.Types.ObjectId(original)}] }
sort = { 'version.major': -1, 'version.minor': -1 }
target = yield Model.findOne(query).sort(sort)
if not target
throw new errors.NotFound('Could not find patch target')
# Enforce permissions
if newStatus in ['rejected', 'accepted']
unless req.user.hasPermission('artisan') or target.hasPermissionsForMethod?(req.user, 'put')
throw new errors.Forbidden('You do not have access to or own the target document.')
if newStatus is 'withdrawn'
unless req.user._id.equals patch.get('creator')
throw new errors.Forbidden('Only the patch creator can withdraw their patch.')
patch.set 'status', newStatus
# Only increment statistics upon very first accept
if patch.isNewlyAccepted()
acceptor = req.user.id
patch.set { acceptor }
yield User.incrementStat acceptor, 'stats.patchesAccepted'
yield patch.save()
target.update {$pull: {patches:patch.get('_id')}}, {}, _.noop
res.send(patch.toObject({req}))

View file

@ -1,179 +1,164 @@
utils = require '../lib/utils'
errors = require '../commons/errors'
User = require '../models/User'
sendwithus = require '../sendwithus'
slack = require '../slack'
_ = require 'lodash'
wrap = require 'co-express'
mongoose = require 'mongoose'
database = require '../commons/database'
parse = require '../commons/parse'
{ notifyChangesMadeToDoc } = require '../commons/notify'
# More info on database versioning: https://github.com/codecombat/codecombat/wiki/Versioning
module.exports =
postNewVersion: (Model, options={}) -> wrap (req, res) ->
# Find the document which is getting a new version
parent = yield database.getDocFromHandle(req, Model)
if not parent
throw new errors.NotFound('Parent not found.')
exports.postNewVersion = (Model, options={}) -> wrap (req, res) ->
# Find the document which is getting a new version
parent = yield database.getDocFromHandle(req, Model)
if not parent
throw new errors.NotFound('Parent not found.')
# Check permissions
# TODO: Figure out an encapsulated way to do this; it's more permissions than versioning
if options.hasPermissionsOrTranslations
permissions = options.hasPermissionsOrTranslations
permissions = [permissions] if _.isString(permissions)
permissions = ['admin'] if not _.isArray(permissions)
hasPermission = _.any(req.user?.hasPermission(permission) for permission in permissions)
if Model.schema.uses_coco_permissions and not hasPermission
hasPermission = parent.hasPermissionsForMethod(req.user, req.method)
if not (hasPermission or database.isJustFillingTranslations(req, parent))
throw new errors.Forbidden()
# Check permissions
# TODO: Figure out an encapsulated way to do this; it's more permissions than versioning
if options.hasPermissionsOrTranslations
permissions = options.hasPermissionsOrTranslations
permissions = [permissions] if _.isString(permissions)
permissions = ['admin'] if not _.isArray(permissions)
hasPermission = _.any(req.user?.hasPermission(permission) for permission in permissions)
if Model.schema.uses_coco_permissions and not hasPermission
hasPermission = parent.hasPermissionsForMethod(req.user, req.method)
if not (hasPermission or database.isJustFillingTranslations(req, parent))
throw new errors.Forbidden()
# Create the new version, a clone of the parent with POST data applied
doc = database.initDoc(req, Model)
ATTRIBUTES_NOT_INHERITED = ['_id', 'version', 'created', 'creator']
doc.set(_.omit(parent.toObject(), ATTRIBUTES_NOT_INHERITED))
database.assignBody(req, doc, { unsetMissing: true })
# Create the new version, a clone of the parent with POST data applied
Model = parent.constructor
doc = database.initDoc(req, Model)
exports.initNewVersion(doc, parent)
database.assignBody(req, doc, { unsetMissing: true })
major = req.body.version?.major
yield exports.saveNewVersion(doc, major)
notifyChangesMadeToDoc(req, doc)
res.status(201).send(doc.toObject())
# Get latest (minor or major) version. This may not be the same document (or same major version) as parent.
latestSelect = 'version index slug'
major = req.body.version?.major
original = parent.get('original')
exports.initNewVersion = (doc, parent) ->
# makes a (mostly) copy of the parent document
ATTRIBUTES_NOT_INHERITED = ['_id', 'version', 'created', 'creator']
doc.set(_.omit(parent.toObject(), ATTRIBUTES_NOT_INHERITED))
return doc
exports.saveNewVersion = wrap (doc, major=null) ->
# Given a document created by initNewVersion and then modified, this sets its versions and updates existing
# versions accordingly.
Model = doc.constructor
# Get latest (minor or major) version. This may not be the same document (or same major version) as parent.
latestSelect = 'version index slug'
original = doc.get('original')
if _.isNumber(major)
q1 = Model.findOne({original: original, 'version.isLatestMinor': true, 'version.major': major})
else
q1 = Model.findOne({original: original, 'version.isLatestMajor': true})
q1.select latestSelect
latest = yield q1.exec()
# Handle the case where no version is marked as latest, since making new
# versions is not atomic
if not latest
if _.isNumber(major)
q1 = Model.findOne({original: original, 'version.isLatestMinor': true, 'version.major': major})
q2 = Model.findOne({original: original, 'version.major': major})
q2.sort({'version.minor': -1})
else
q1 = Model.findOne({original: original, 'version.isLatestMajor': true})
q1.select latestSelect
latest = yield q1.exec()
# Handle the case where no version is marked as latest, since making new
# versions is not atomic
q2 = Model.findOne()
q2.sort({'version.major': -1, 'version.minor': -1})
q2.select(latestSelect)
latest = yield q2.exec()
if not latest
if _.isNumber(major)
q2 = Model.findOne({original: original, 'version.major': major})
q2.sort({'version.minor': -1})
else
q2 = Model.findOne()
q2.sort({'version.major': -1, 'version.minor': -1})
q2.select(latestSelect)
latest = yield q2.exec()
if not latest
throw new errors.NotFound('Previous version not found.')
throw new errors.NotFound('Previous version not found.')
# Update the latest version, making it no longer the latest. This includes
major = req.body.version?.major
version = _.clone(latest.get('version'))
wasLatestMajor = version.isLatestMajor
version.isLatestMajor = false
if _.isNumber(major)
version.isLatestMinor = false
raw = yield latest.update({$set: {version: version}, $unset: {index: 1, slug: 1}})
if not raw.nModified
console.error('Conditions', conditions)
console.error('Doc', doc)
console.error('Raw response', raw)
throw new errors.InternalServerError('Latest version could not be modified.')
# Update the latest version, making it no longer the latest.
version = _.clone(latest.get('version'))
wasLatestMajor = version.isLatestMajor
version.isLatestMajor = false
if _.isNumber(major)
version.isLatestMinor = false
raw = yield latest.update({$set: {version: version}, $unset: {index: 1, slug: 1}})
if not raw.nModified
console.error('Conditions', conditions)
console.error('Doc', doc)
console.error('Raw response', raw)
throw new errors.InternalServerError('Latest version could not be modified.')
# update the new doc with version, index information
# Relying heavily on Mongoose schema default behavior here. TODO: Make explicit?
if _.isNumber(major)
doc.set({
'version.major': latest.version.major
'version.minor': latest.version.minor + 1
'version.isLatestMajor': wasLatestMajor
})
if wasLatestMajor
doc.set('index', true)
else
doc.set({index: undefined, slug: undefined})
else
doc.set('version.major', latest.version.major + 1)
# update the new doc with version, index information
# Relying heavily on Mongoose schema default behavior here. TODO: Make explicit?
if _.isNumber(major)
doc.set({
'version.major': latest.version.major
'version.minor': latest.version.minor + 1
'version.isLatestMajor': wasLatestMajor
})
if wasLatestMajor
doc.set('index', true)
else
doc.set({index: undefined, slug: undefined})
else
doc.set('version.major', latest.version.major + 1)
doc.set('index', true)
doc.set('parent', latest._id)
doc.set('parent', latest._id)
try
doc = yield doc.save()
catch e
# Revert changes to latest doc made earlier, should set everything back to normal
yield latest.update({$set: _.pick(latest.toObject(), 'version', 'index', 'slug')})
throw e
try
doc = yield doc.save()
catch e
# Revert changes to latest doc made earlier, should set everything back to normal
yield latest.update({$set: _.pick(latest.toObject(), 'version', 'index', 'slug')})
throw e
editPath = req.headers['x-current-path']
docLink = "http://codecombat.com#{editPath}"
# Post a message on Slack
message = "#{req.user.get('name')} saved a change to #{doc.get('name')}: #{doc.get('commitMessage') or '(no commit message)'} #{docLink}"
slack.sendSlackMessage message, ['artisans']
# Send emails to watchers
watchers = doc.get('watchers') or []
# Don't send these emails to the person who submitted the patch, or to Nick, George, or Scott.
watchers = (w for w in watchers when not w.equals(req.user.get('_id')) and not (w + '' in ['512ef4805a67a8c507000001', '5162fab9c92b4c751e000274', '51538fdb812dd9af02000001']))
if watchers.length
User.find({_id:{$in:watchers}}).select({email:1, name:1}).exec (err, watchers) ->
for watcher in watchers
continue if not watcher.get('email')
context =
email_id: sendwithus.templates.change_made_notify_watcher
recipient:
address: watcher.get('email')
name: watcher.get('name')
email_data:
doc_name: doc.get('name') or '???'
submitter_name: req.user.get('name') or '???'
doc_link: if editPath then docLink else null
commit_message: doc.get('commitMessage')
sendwithus.api.send context, _.noop
res.status(201).send(doc.toObject())
return doc
exports.getLatestVersion = (Model, options={}) -> wrap (req, res) ->
# can get latest overall version, latest of a major version, or a specific version
original = req.params.handle
version = req.params.version
if not database.isID(original)
throw new errors.UnprocessableEntity('Invalid MongoDB id: '+original)
getLatestVersion: (Model, options={}) -> wrap (req, res) ->
# can get latest overall version, latest of a major version, or a specific version
original = req.params.handle
version = req.params.version
if not database.isID(original)
throw new errors.UnprocessableEntity('Invalid MongoDB id: '+original)
query = { 'original': mongoose.Types.ObjectId(original) }
if version?
version = version.split('.')
majorVersion = parseInt(version[0])
minorVersion = parseInt(version[1])
query['version.major'] = majorVersion unless _.isNaN(majorVersion)
query['version.minor'] = minorVersion unless _.isNaN(minorVersion)
dbq = Model.findOne(query)
query = { 'original': mongoose.Types.ObjectId(original) }
if version?
version = version.split('.')
majorVersion = parseInt(version[0])
minorVersion = parseInt(version[1])
query['version.major'] = majorVersion unless _.isNaN(majorVersion)
query['version.minor'] = minorVersion unless _.isNaN(minorVersion)
dbq = Model.findOne(query)
dbq.sort({ 'version.major': -1, 'version.minor': -1 })
dbq.sort({ 'version.major': -1, 'version.minor': -1 })
# Make sure that permissions and version are fetched, but not sent back if they didn't ask for them.
projection = parse.getProjectFromReq(req)
if projection
extraProjectionProps = []
extraProjectionProps.push 'permissions' unless projection.permissions
extraProjectionProps.push 'version' unless projection.version
projection.permissions = 1
projection.version = 1
dbq.select(projection)
# Make sure that permissions and version are fetched, but not sent back if they didn't ask for them.
projection = parse.getProjectFromReq(req)
if projection
extraProjectionProps = []
extraProjectionProps.push 'permissions' unless projection.permissions
extraProjectionProps.push 'version' unless projection.version
projection.permissions = 1
projection.version = 1
dbq.select(projection)
doc = yield dbq.exec()
throw new errors.NotFound() if not doc
throw new errors.Forbidden() unless database.hasAccessToDocument(req, doc)
doc = _.omit doc, extraProjectionProps if extraProjectionProps?
doc = yield dbq.exec()
throw new errors.NotFound() if not doc
throw new errors.Forbidden() unless database.hasAccessToDocument(req, doc)
doc = _.omit doc, extraProjectionProps if extraProjectionProps?
res.status(200).send(doc.toObject())
res.status(200).send(doc.toObject())
versions: (Model, options={}) -> wrap (req, res) ->
original = req.params.handle
dbq = Model.find({'original': mongoose.Types.ObjectId(original)})
dbq.sort({'created': -1})
dbq.limit(parse.getLimitFromReq(req))
dbq.skip(parse.getSkipFromReq(req))
dbq.select(parse.getProjectFromReq(req) or 'slug name version commitMessage created creator permissions')
exports.versions = (Model, options={}) -> wrap (req, res) ->
original = req.params.handle
dbq = Model.find({'original': mongoose.Types.ObjectId(original)})
dbq.sort({'created': -1})
dbq.limit(parse.getLimitFromReq(req))
dbq.skip(parse.getSkipFromReq(req))
dbq.select(parse.getProjectFromReq(req) or 'slug name version commitMessage created creator permissions')
results = yield dbq.exec()
res.status(200).send(results)
results = yield dbq.exec()
res.status(200).send(results)

View file

@ -9,6 +9,7 @@ CourseSchema = new mongoose.Schema {}, {strict: false, minimize: false, read:con
CourseSchema.plugin plugins.NamedPlugin
CourseSchema.plugin plugins.SearchablePlugin, {searchable: ['name', 'description']}
CourseSchema.plugin(plugins.TranslationCoveragePlugin)
CourseSchema.plugin(plugins.PatchablePlugin)
CourseSchema.statics.privateProperties = []
CourseSchema.statics.editableProperties = [

View file

@ -1,6 +1,6 @@
mongoose = require 'mongoose'
plugins = require '../plugins/plugins'
jsonschema = require '../../app/schemas/models/level'
jsonSchema = require '../../app/schemas/models/level'
config = require '../../server_config'
LevelSchema = new mongoose.Schema({
@ -47,6 +47,7 @@ LevelSchema.post 'init', (doc) ->
doc.set('nextLevel', undefined)
LevelSchema.statics.postEditableProperties = ['name']
LevelSchema.statics.jsonSchema = jsonSchema
LevelSchema.statics.editableProperties = [
'description'

View file

@ -1,6 +1,6 @@
mongoose = require 'mongoose'
plugins = require '../plugins/plugins'
jsonschema = require '../../app/schemas/models/level_component'
jsonSchema = require '../../app/schemas/models/level_component'
config = require '../../server_config'
LevelComponentSchema = new mongoose.Schema {
@ -51,4 +51,6 @@ LevelComponentSchema.pre('save', (next) ->
next()
)
LevelComponentSchema.statics.jsonSchema = jsonSchema
module.exports = LevelComponent = mongoose.model('level.component', LevelComponentSchema)

View file

@ -1,6 +1,6 @@
mongoose = require 'mongoose'
plugins = require '../plugins/plugins'
jsonschema = require '../../app/schemas/models/level_system'
jsonSchema = require '../../app/schemas/models/level_system'
config = require '../../server_config'
LevelSystemSchema = new mongoose.Schema {
@ -33,6 +33,8 @@ LevelSystemSchema.index(
})
LevelSystemSchema.index({slug: 1}, {name: 'slug index', sparse: true, unique: true})
LevelSystemSchema.statics.jsonSchema = jsonSchema
LevelSystemSchema.plugin(plugins.NamedPlugin)
LevelSystemSchema.plugin(plugins.PermissionsPlugin)
LevelSystemSchema.plugin(plugins.VersionedPlugin)

View file

@ -7,56 +7,6 @@ config = require '../../server_config'
PatchSchema = new mongoose.Schema({status: String}, {strict: false,read:config.mongo.readpref})
PatchSchema.index({'target.original': 1, 'status': 1}, {name: 'target_status'})
PatchSchema.pre 'save', (next) ->
return next() unless @isNew # patch can't be altered after creation, so only need to check data once
target = @get('target')
return next() if target.collection is 'course' # Migrating this logic out of the Patch model, into middleware
targetID = target.id
Handler = require '../commons/Handler'
if not Handler.isID(targetID)
err = new Error('Invalid input.')
err.response = {message: 'isn\'t a MongoDB id.', property: 'target.id'}
err.code = 422
return next(err)
collection = target.collection
try
handler = require('../' + handlers[collection])
catch err
console.error 'Couldn\'t find handler for collection:', target.collection, 'from target', target
err = new Error('Server error.')
err.response = {message: '', property: 'target.id'}
err.code = 500
return next(err)
handler.getDocumentForIdOrSlug targetID, (err, document) =>
if err
err = new Error('Server error.')
err.response = {message: '', property: 'target.id'}
err.code = 500
return next(err)
if not document
err = new Error('Target of patch not found.')
err.response = {message: 'was not found.', property: 'target.id'}
err.code = 404
return next(err)
target.id = document.get('_id')
if handler.modelClass.schema.uses_coco_versions
target.original = document.get('original')
version = document.get('version')
target.version = _.pick document.get('version'), 'major', 'minor'
@set('target', target)
else
target.original = targetID
patches = document.get('patches') or []
patches = _.clone patches
patches.push @_id
document.set 'patches', patches, {strict: false}
@targetLoaded = document
document.save (err) -> next(err)
PatchSchema.methods.isTranslationPatch = -> # Don't ever fat arrow bind this one
expanded = deltas.flattenDelta @get('delta')
_.some expanded, (delta) -> 'i18n' in delta.dataPath
@ -77,11 +27,18 @@ PatchSchema.methods.wasPending = -> @get 'wasPending'
PatchSchema.pre 'save', (next) ->
User = require './User'
userID = @get('creator').toHexString()
collection = @get('target.collection')
# Increment patch submitter stats when the patch is "accepted".
# Does not handle if the patch is accepted multiple times, but that's an edge case.
if @get('status') is 'accepted'
User.incrementStat userID, 'stats.patchesContributed' # accepted patches
else if @get('status') is 'pending'
User.incrementStat userID, 'stats.patchesSubmitted' # submitted patches
if @isTranslationPatch()
User.incrementStat(userID, 'stats.totalTranslationPatches')
User.incrementStat(userID, User.statsMapping.translations[collection])
if @isMiscPatch()
User.incrementStat(userID, 'stats.totalMiscPatches')
User.incrementStat(userID, User.statsMapping.misc[collection])
next()

View file

@ -1,6 +1,7 @@
mongoose = require 'mongoose'
plugins = require '../plugins/plugins'
config = require '../../server_config'
jsonSchema = require '../../app/schemas/models/thang_type.coffee'
ThangTypeSchema = new mongoose.Schema({
body: String,
@ -32,6 +33,8 @@ ThangTypeSchema.index(
})
ThangTypeSchema.index({slug: 1}, {name: 'slug index', sparse: true, unique: true})
ThangTypeSchema.statics.jsonSchema = jsonSchema
ThangTypeSchema.plugin plugins.NamedPlugin
ThangTypeSchema.plugin plugins.VersionedPlugin
ThangTypeSchema.plugin plugins.SearchablePlugin, {searchable: ['name']}

View file

@ -221,9 +221,13 @@ UserSchema.statics.statsMapping =
article: 'stats.articleEdits'
level: 'stats.levelEdits'
'level.component': 'stats.levelComponentEdits'
'level_component': 'stats.levelComponentEdits'
'level.system': 'stats.levelSystemEdits'
'level_system': 'stats.levelSystemEdits'
'thang.type': 'stats.thangTypeEdits'
'thang_type': 'stats.thangTypeEdits'
'Achievement': 'stats.achievementEdits'
'achievement': 'stats.achievementEdits'
'campaign': 'stats.campaignEdits'
'poll': 'stats.pollEdits'
'course': 'stats.courseEdits'
@ -231,9 +235,13 @@ UserSchema.statics.statsMapping =
article: 'stats.articleTranslationPatches'
level: 'stats.levelTranslationPatches'
'level.component': 'stats.levelComponentTranslationPatches'
'level_component': 'stats.levelComponentTranslationPatches'
'level.system': 'stats.levelSystemTranslationPatches'
'level_system': 'stats.levelSystemTranslationPatches'
'thang.type': 'stats.thangTypeTranslationPatches'
'thang_type': 'stats.thangTypeTranslationPatches'
'Achievement': 'stats.achievementTranslationPatches'
'achievement': 'stats.achievementTranslationPatches'
'campaign': 'stats.campaignTranslationPatches'
'poll': 'stats.pollTranslationPatches'
'course': 'stats.courseTranslationPatches'
@ -241,20 +249,34 @@ UserSchema.statics.statsMapping =
article: 'stats.articleMiscPatches'
level: 'stats.levelMiscPatches'
'level.component': 'stats.levelComponentMiscPatches'
'level_component': 'stats.levelComponentMiscPatches'
'level.system': 'stats.levelSystemMiscPatches'
'level_system': 'stats.levelSystemMiscPatches'
'thang.type': 'stats.thangTypeMiscPatches'
'thang_type': 'stats.thangTypeMiscPatches'
'Achievement': 'stats.achievementMiscPatches'
'achievement': 'stats.achievementMiscPatches'
'campaign': 'stats.campaignMiscPatches'
'poll': 'stats.pollMiscPatches'
'course': 'stats.courseMiscPatches'
UserSchema.statics.incrementStat = (id, statName, done, inc=1) ->
id = mongoose.Types.ObjectId id if _.isString id
@findById id, (err, user) ->
log.error err if err?
err = new Error "Could't find user with id '#{id}'" unless user or err
return done() if err?
user.incrementStat statName, done, inc
# TODO: Migrate from incrementStat to incrementStatAsync
UserSchema.statics.incrementStatAsync = Promise.promisify (id, statName, options={}, done) ->
# A shim over @incrementStat, providing a Promise interface
if _.isFunction(options)
done = options
options = {}
@incrementStat(id, statName, done, options.inc or 1)
UserSchema.statics.incrementStat = (id, statName, done=_.noop, inc=1) ->
_id = if _.isString(id) then mongoose.Types.ObjectId(id) else id
update = {$inc: {}}
update.$inc[statName] = inc
@update({_id}, update).exec((err, res) ->
if not res.nModified
log.warn "Did not update user stat '#{statName}' for '#{id}'"
done?()
)
UserSchema.methods.incrementStat = (statName, done, inc=1) ->
if /^concepts\./.test statName

View file

@ -31,6 +31,7 @@ module.exports.setup = (app) ->
app.get('/db/achievement/names', mw.named.names(Achievement))
app.post('/db/achievement/names', mw.named.names(Achievement))
app.get('/db/achievement/:handle/patches', mw.patchable.patches(Achievement))
app.post('/db/achievement/:handle/patch', mw.auth.checkLoggedIn(), mw.patchable.postPatch(Achievement, 'achievement'))
app.post('/db/achievement/:handle/watchers', mw.patchable.joinWatchers(Achievement))
app.delete('/db/achievement/:handle/watchers', mw.patchable.leaveWatchers(Achievement))
@ -47,6 +48,7 @@ module.exports.setup = (app) ->
app.get('/db/article/:handle/version/?(:version)?', mw.versions.getLatestVersion(Article))
app.get('/db/article/:handle/files', mw.files.files(Article, {module: 'article'}))
app.get('/db/article/:handle/patches', mw.patchable.patches(Article))
app.post('/db/article/:handle/patch', mw.auth.checkLoggedIn(), mw.patchable.postPatch(Article, 'article'))
app.post('/db/article/:handle/watchers', mw.patchable.joinWatchers(Article))
app.delete('/db/article/:handle/watchers', mw.patchable.leaveWatchers(Article))
@ -60,6 +62,7 @@ module.exports.setup = (app) ->
app.get('/db/campaign/:handle/achievements', mw.campaigns.fetchRelatedAchievements)
app.get('/db/campaign/:handle/levels', mw.campaigns.fetchRelatedLevels)
app.get('/db/campaign/:handle/patches', mw.patchable.patches(Campaign))
app.post('/db/campaign/:handle/patch', mw.auth.checkLoggedIn(), mw.patchable.postPatch(Campaign, 'campaign'))
app.get('/db/campaign/-/overworld', mw.campaigns.fetchOverworld)
app.post('/db/classroom', mw.classrooms.post)
@ -87,7 +90,7 @@ module.exports.setup = (app) ->
app.get('/db/course/:handle', mw.rest.getByHandle(Course))
app.get('/db/course/:handle/level-solutions', mw.courses.fetchLevelSolutions)
app.get('/db/course/:handle/levels/:levelOriginal/next', mw.courses.fetchNextLevel)
app.post('/db/course/:handle/patch', mw.auth.checkLoggedIn(), mw.courses.postPatch)
app.post('/db/course/:handle/patch', mw.auth.checkLoggedIn(), mw.patchable.postPatch(Course, 'course'))
app.get('/db/course/:handle/patches', mw.patchable.patches(Course))
app.get('/db/course_instance/-/non-hoc', mw.auth.checkHasPermission(['admin']), mw.courseInstances.fetchNonHoc)
@ -104,6 +107,16 @@ module.exports.setup = (app) ->
app.post('/db/level/names', mw.named.names(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)
app.post('/db/level/:handle/patch', mw.auth.checkLoggedIn(), mw.patchable.postPatch(Level, 'level'))
app.get('/db/level/:handle/patches', mw.patchable.patches(Level))
LevelComponent = require '../models/LevelComponent'
app.post('/db/level.component/:handle/patch', mw.auth.checkLoggedIn(), mw.patchable.postPatch(LevelComponent, 'level_component'))
app.get('/db/level.component/:handle/patches', mw.patchable.patches(LevelComponent))
LevelSystem = require '../models/LevelSystem'
app.post('/db/level.system/:handle/patch', mw.auth.checkLoggedIn(), mw.patchable.postPatch(LevelSystem, 'level_system'))
app.get('/db/level.system/:handle/patches', mw.patchable.patches(LevelSystem))
app.put('/db/user/:handle', mw.users.resetEmailVerifiedFlag)
app.delete('/db/user/:handle', mw.users.removeFromClassrooms)
@ -121,6 +134,13 @@ module.exports.setup = (app) ->
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.post('/db/patch', mw.patches.post)
app.put('/db/patch/:handle/status', mw.auth.checkLoggedIn(), mw.patches.setStatus)
Poll = require '../models/Poll'
app.post('/db/poll/:handle/patch', mw.auth.checkLoggedIn(), mw.patchable.postPatch(Poll, 'poll'))
app.get('/db/poll/:handle/patches', mw.patchable.patches(Poll))
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)
@ -128,6 +148,10 @@ module.exports.setup = (app) ->
app.get '/db/products', require('./db/product').get
ThangType = require '../models/ThangType'
app.post('/db/thang.type/:handle/patch', mw.auth.checkLoggedIn(), mw.patchable.postPatch(ThangType, 'thang_type'))
app.get('/db/thang.type/:handle/patches', mw.patchable.patches(ThangType))
TrialRequest = require '../models/TrialRequest'
app.get('/db/trial.request', mw.trialRequests.fetchByApplicant, mw.auth.checkHasPermission(['admin']), mw.rest.get(TrialRequest))
app.post('/db/trial.request', mw.trialRequests.post)

View file

@ -600,13 +600,14 @@ describe 'GET /db/article/:handle/patches', ->
[res, article] = yield request.postAsync { uri: getURL('/db/article'), json: articleData }
expect(res.statusCode).toBe(201)
[res, patch] = yield request.postAsync { uri: getURL('/db/patch'), json: {
delta: []
delta: {name:['test']}
commitMessage: 'Test commit'
target: {
collection: 'article'
id: article._id
}
}}
expect(res.statusCode).toBe(201)
[res, patches] = yield request.getAsync getURL("/db/article/#{article._id}/patches"), { json: true }
expect(res.statusCode).toBe(200)
expect(patches.length).toBe(1)
@ -680,3 +681,48 @@ describe 'DELETE /db/article/:handle/watchers', ->
ids = (id.toString() for id in article.get('watchers'))
expect(_.contains(ids, user.id)).toBe(false)
done()
describe 'POST /db/article/:handle/patch', ->
it 'creates a new version if the changes are translation only', utils.wrap (done) ->
# create article
yield utils.clearModels([Article])
admin = yield utils.initAdmin()
yield utils.loginUser(admin)
article = yield utils.makeArticle()
# submit a translation patch
user = yield utils.initUser()
yield utils.loginUser(user)
originalArticle = article.toObject()
changedArticle = _.extend({}, originalArticle, {i18n: {de: {name: 'Name in German'}}})
json = {
delta: jsondiffpatch.diff(originalArticle, changedArticle)
commitMessage: 'Server test commit'
target: {
collection: 'article'
id: article.id
}
}
url = utils.getURL("/db/article/#{article.id}/patch")
[res, body] = yield request.postAsync({ url, json })
expect(res.statusCode).toBe(201)
[firstArticle, secondArticle] = yield Article.find().sort('_id')
expectedVersion = { isLatestMinor: false, isLatestMajor: false, minor: 0, major: 0 }
expect(_.isEqual(firstArticle.get('version'), expectedVersion)).toBe(true)
expectedVersion = { isLatestMinor: true, isLatestMajor: true, minor: 1, major: 0 }
expect(_.isEqual(secondArticle.get('version'), expectedVersion)).toBe(true)
expect(firstArticle.get('i18n.de.name')).toBe(undefined)
expect(secondArticle.get('i18n.de.name')).toBe('Name in German')
expect(firstArticle.get('creator').equals(admin._id)).toBe(true)
expect(secondArticle.get('creator').equals(user._id)).toBe(true)
expect(firstArticle.get('commitMessage')).toBeUndefined()
expect(secondArticle.get('commitMessage')).toBe('Server test commit')
done()

View file

@ -10,6 +10,7 @@ Classroom = require '../../../server/models/Classroom'
Campaign = require '../../../server/models/Campaign'
Level = require '../../../server/models/Level'
Patch = require '../../../server/models/Patch'
User = require '../../../server/models/User'
courseFixture = {
name: 'Unnamed course'
@ -233,8 +234,8 @@ describe 'GET /db/course/:handle/level-solutions', ->
describe 'POST /db/course/:handle/patch', ->
beforeEach utils.wrap (done) ->
yield utils.clearModels [User, Course]
admin = yield utils.initAdmin()
yield utils.loginUser(admin)
@admin = yield utils.initAdmin()
yield utils.loginUser(@admin)
@course = yield utils.makeCourse({
name: 'Test Course'
@ -253,7 +254,7 @@ describe 'POST /db/course/:handle/patch', ->
}
done()
it 'saves the changes immediately if just adding new translations to existing langauge', utils.wrap (done) ->
it 'accepts the patch immediately if just adding new translations to existing language', utils.wrap (done) ->
originalCourse = _.cloneDeep(@course.toObject())
changedCourse = _.cloneDeep(@course.toObject())
changedCourse.i18n.de.description = 'German translation!'
@ -265,9 +266,18 @@ describe 'POST /db/course/:handle/patch', ->
expect(course.get('i18n').de.description).toBe('German translation!')
expect(course.get('patches')).toBeUndefined()
expect(_.contains(course.get('i18nCoverage'),'de')).toBe(true)
yield new Promise((resolve) -> setTimeout(resolve, 10))
admin = yield User.findById(@admin.id)
expected = {
patchesSubmitted: 1,
courseTranslationPatches: 1,
totalTranslationPatches: 1,
patchesContributed: 1
}
expect(_.isEqual(admin.get('stats'), expected)).toBe(true)
done()
it 'saves the changes immediately if translations are for a new langauge', utils.wrap (done) ->
it 'accepts the patch immediately if translations are for a new language', utils.wrap (done) ->
originalCourse = _.cloneDeep(@course.toObject())
changedCourse = _.cloneDeep(@course.toObject())
changedCourse.i18n.fr = { description: 'French translation!' }
@ -296,6 +306,12 @@ describe 'POST /db/course/:handle/patch', ->
expect(patch.get('reasonNotAutoAccepted')).toBe('Adding to existing translations.')
[res, body] = yield request.getAsync({ url: utils.getURL("/db/course/#{@course.id}/patches?status=pending"), json: true })
expect(res.body[0]._id).toBe(patch.id)
yield new Promise((resolve) -> setTimeout(resolve, 10))
admin = yield User.findById(@admin.id)
expected = {
patchesSubmitted: 1
}
expect(_.isEqual(admin.get('stats'), expected)).toBe(true)
done()
it 'saves a patch if applying the patch would invalidate the course data', utils.wrap (done) ->
@ -327,6 +343,8 @@ describe 'POST /db/course/:handle/patch', ->
expect(course.get('i18n').de.description).toBe('Race condition')
expect(course.get('patches').length).toBe(1)
patch = yield Patch.findById(course.get('patches')[0])
expect(_.isEqual(patch.get('delta'), @json.delta)).toBe(true)
# will have been normalized to include that it has been modified from "Race condition"
expectedDelta = {"i18n":{"de":{"description":["Race condition","German translation!"]}}}
expect(_.isEqual(patch.get('delta'), expectedDelta)).toBe(true)
expect(patch.get('reasonNotAutoAccepted')).toBe('Adding to existing translations.')
done()

View file

@ -4,163 +4,170 @@ Article = require '../../../server/models/Article'
Patch = require '../../../server/models/Patch'
request = require '../request'
utils = require '../utils'
co = require 'co'
Promise = require 'bluebird'
describe '/db/patch', ->
async = require 'async'
UserHandler = require '../../../server/handlers/user_handler'
it 'clears the db first', (done) ->
clearModels [User, Article, Patch], (err) ->
throw err if err
done()
article = {name: 'Yo', body: 'yo ma'}
articleURL = getURL('/db/article')
articles = {}
patchURL = getURL('/db/patch')
patches = {}
patch =
makeArticle = utils.wrap (done) ->
@creator = yield utils.initAdmin()
yield utils.loginUser(@creator)
@article = yield utils.makeArticle()
@json = {
commitMessage: 'Accept this patch!'
delta: {name: ['test']}
editPath: '/who/knows/yes'
delta: { name: ['test'] }
target:
id: null
id: @article.id
collection: 'article'
}
@url = utils.getURL('/db/patch')
@user = yield utils.initUser()
yield utils.loginUser(@user)
done()
it 'creates an Article to patch', (done) ->
loginAdmin ->
request.post {uri: articleURL, json: article}, (err, res, body) ->
articles[0] = body
patch.target.id = articles[0]._id
done()
describe 'POST /db/patch', ->
beforeEach utils.wrap (done) ->
yield utils.clearModels([User, Patch, Article])
done()
it 'allows someone to submit a patch to something they don\'t control', (done) ->
loginJoe (joe) ->
request.post {uri: patchURL, json: patch}, (err, res, body) ->
expect(res.statusCode).toBe(200)
expect(body.target.original).toBeDefined()
expect(body.target.version.major).toBeDefined()
expect(body.target.version.minor).toBeDefined()
expect(body.status).toBe('pending')
expect(body.created).toBeDefined()
expect(body.creator).toBe(joe.id)
patches[0] = body
done()
beforeEach makeArticle
it 'adds a patch to the target document', (done) ->
Article.findOne({}).exec (err, article) ->
expect(article.toObject().patches[0]).toBeDefined()
done()
it 'allows someone to submit a patch to something they don\'t control', utils.wrap (done) ->
[res, body] = yield request.postAsync { @url, @json }
expect(res.statusCode).toBe(201)
expect(body.target.original).toBe(@article.get('original').toString())
expect(body.target.version.major).toBeDefined()
expect(body.target.version.minor).toBeDefined()
expect(body.status).toBe('pending')
expect(body.created).toBeDefined()
expect(body.creator).toBe(@user.id)
done()
it 'shows up in patch requests', (done) ->
patchesURL = getURL("/db/article/#{articles[0]._id}/patches")
request.get {uri: patchesURL}, (err, res, body) ->
body = JSON.parse(body)
expect(res.statusCode).toBe(200)
expect(body.length).toBe(1)
done()
it 'adds a patch to the target document', utils.wrap (done) ->
[res, body] = yield request.postAsync { @url, @json }
article = yield Article.findById(@article.id)
expect(article.get('patches').length).toBe(1)
done()
it 'allows you to set yourself as watching', (done) ->
watchingURL = getURL("/db/article/#{articles[0]._id}/watch")
request.put {uri: watchingURL, json: {on: true}}, (err, res, body) ->
expect(body.watchers[1]).toBeDefined()
done()
it 'shows up in patch requests', utils.wrap (done) ->
[res, body] = yield request.postAsync { @url, @json }
patchID = res.body._id
url = utils.getURL("/db/article/#{@article.id}/patches")
[res, body] = yield request.getAsync { url, json: true }
expect(res.statusCode).toBe(200)
expect(body.length).toBe(1)
expect(body[0]._id).toBe(patchID)
done()
it 'added the watcher to the target document', (done) ->
Article.findOne({}).exec (err, article) ->
expect(article.toObject().watchers[1]).toBeDefined()
done()
it 'accepts all patchable collections', utils.wrap (done) ->
admin = yield utils.initAdmin()
yield utils.loginUser(admin)
it 'does not add duplicate watchers', (done) ->
watchingURL = getURL("/db/article/#{articles[0]._id}/watch")
request.put {uri: watchingURL, json: {on: true}}, (err, res, body) ->
expect(body.watchers.length).toBe(3)
done()
targets = [
{ collection: 'achievement', modelPromise: utils.makeAchievement() }
{ collection: 'article', modelPromise: utils.makeArticle() }
{ collection: 'campaign', modelPromise: utils.makeCampaign() }
{ collection: 'course', modelPromise: utils.makeCourse() }
{ collection: 'level', modelPromise: utils.makeLevel() }
{ collection: 'level_component', modelPromise: utils.makeLevelComponent() }
{ collection: 'level_system', modelPromise: utils.makeLevelSystem() }
{ collection: 'poll', modelPromise: utils.makePoll() }
{ collection: 'thang_type', modelPromise: utils.makeThangType() }
]
it 'allows removing yourself', (done) ->
watchingURL = getURL("/db/article/#{articles[0]._id}/watch")
request.put {uri: watchingURL, json: {on: false}}, (err, res, body) ->
expect(body.watchers.length).toBe(2)
done()
# concisely test everything in parallel
promises = targets.map((target) => co =>
model = yield target.modelPromise
json = {
commitMessage: 'Accept this patch!'
delta: { name: ['test'] }
target:
id: model.id
collection: target.collection
}
[res, body] = yield request.postAsync { @url, json }
expect(res.statusCode).toBe(201)
)
yield promises
count = yield Patch.count()
expect(count).toBe(targets.length) # make sure all patches got created
done()
it 'allows the submitter to withdraw the pull request', (done) ->
statusURL = getURL("/db/patch/#{patches[0]._id}/status")
request.put {uri: statusURL, json: {status: 'withdrawn'}}, (err, res, body) ->
expect(res.statusCode).toBe(200)
Patch.findOne({}).exec (err, article) ->
expect(article.get('status')).toBe 'withdrawn'
Article.findOne({}).exec (err, article) ->
expect(article.toObject().patches.length).toBe(0)
done()
describe 'PUT /db/:collection/:handle/watch', ->
beforeEach makeArticle
it 'does not allow the submitter to reject or accept the pull request', (done) ->
statusURL = getURL("/db/patch/#{patches[0]._id}/status")
request.put {uri: statusURL, json: {status: 'rejected'}}, (err, res, body) ->
expect(res.statusCode).toBe(403)
request.put {uri: statusURL, json: {status: 'accepted'}}, (err, res, body) ->
expect(res.statusCode).toBe(403)
Patch.findOne({}).exec (err, article) ->
expect(article.get('status')).toBe 'withdrawn'
done()
it 'adds the user to the list of watchers idempotently when body is {on: true}', utils.wrap (done) ->
url = getURL("/db/article/#{@article.id}/watch")
[res, body] = yield request.putAsync({url, json: {on: true}})
expect(body.watchers[1]).toBeDefined()
expect(_.last(body.watchers)).toBe(@user.id)
previousWatchers = body.watchers
[res, body] = yield request.putAsync({url, json: {on: true}})
expect(_.isEqual(previousWatchers, body.watchers)).toBe(true)
done()
it 'allows the recipient to accept or reject the pull request', (done) ->
statusURL = getURL("/db/patch/#{patches[0]._id}/status")
loginAdmin ->
request.put {uri: statusURL, json: {status: 'rejected'}}, (err, res, body) ->
expect(res.statusCode).toBe(200)
Patch.findOne({}).exec (err, article) ->
expect(article.get('status')).toBe 'rejected'
request.put {uri: statusURL, json: {status: 'accepted'}}, (err, res, body) ->
expect(res.statusCode).toBe(200)
Patch.findOne({}).exec (err, article) ->
expect(article.get('status')).toBe 'accepted'
expect(article.get('acceptor')).toBeDefined()
done()
it 'removes user from the list of watchers when body is {on: false}', utils.wrap (done) ->
url = getURL("/db/article/#{@article.id}/watch")
[res, body] = yield request.putAsync({url, json: {on: true}})
expect(_.contains(body.watchers, @user.id)).toBe(true)
[res, body] = yield request.putAsync({url, json: {on: false}})
expect(_.contains(body.watchers, @user.id)).toBe(false)
done()
it 'keeps track of amount of submitted and accepted patches', (done) ->
loginJoe (joe) ->
User.findById joe.get('_id'), (err, guy) ->
expect(err).toBeNull()
expect(guy.get 'stats.patchesSubmitted').toBe 1
expect(guy.get 'stats.patchesContributed').toBe 1
expect(guy.get 'stats.totalMiscPatches').toBe 1
expect(guy.get 'stats.articleMiscPatches').toBe 1
expect(guy.get 'stats.totalTranslationPatches').toBeUndefined()
done()
it 'recalculates amount of submitted and accepted patches', (done) ->
loginJoe (joe) ->
User.findById joe.get('_id'), (err, joe) ->
expect(joe.get 'stats.patchesSubmitted').toBe 1
joe.update {$unset: stats: ''}, (err) ->
UserHandler.modelClass.findById joe.get('_id'), (err, joe) ->
expect(err).toBeNull()
expect(joe.get 'stats').toBeUndefined()
async.parallel [
(done) -> UserHandler.recalculateStats 'patchesContributed', done
(done) -> UserHandler.recalculateStats 'patchesSubmitted', done
(done) -> UserHandler.recalculateStats 'totalMiscPatches', done
(done) -> UserHandler.recalculateStats 'totalTranslationPatches', done
(done) -> UserHandler.recalculateStats 'articleMiscPatches', done
], (err) ->
expect(err).toBeNull()
UserHandler.modelClass.findById joe.get('_id'), (err, joe) ->
expect(joe.get 'stats.patchesSubmitted').toBe 1
expect(joe.get 'stats.patchesContributed').toBe 1
expect(joe.get 'stats.totalMiscPatches').toBe 1
expect(joe.get 'stats.articleMiscPatches').toBe 1
expect(joe.get 'stats.totalTranslationPatches').toBeUndefined()
done()
describe 'PUT /db/patch/:handle/status', ->
beforeEach makeArticle
it 'does not allow the recipient to withdraw the pull request', (done) ->
loginAdmin ->
statusURL = getURL("/db/patch/#{patches[0]._id}/status")
request.put {uri: statusURL, json: {status:'withdrawn'}}, (err, res, body) ->
expect(res.statusCode).toBe(403)
Patch.findOne({}).exec (err, article) ->
expect(article.get('status')).toBe 'accepted'
done()
beforeEach utils.wrap (done) ->
[res, body] = yield request.postAsync { url: utils.getURL('/db/patch'), @json }
@patchID = body._id
@url = utils.getURL("/db/patch/#{@patchID}/status")
done()
it 'withdraws the submitter\'s patch', utils.wrap (done) ->
[res, body] = yield request.putAsync {@url, json: {status: 'withdrawn'}}
expect(res.statusCode).toBe(200)
expect(body.status).toBe('withdrawn')
article = yield Article.findById(@article.id)
expect(article.get('patches').length).toBe(0)
done()
it 'does not allow the submitter to reject or accept the pull request', utils.wrap (done) ->
[res, body] = yield request.putAsync {@url, json: {status: 'rejected'}}
expect(res.statusCode).toBe(403)
[res, body] = yield request.putAsync {@url, json: {status: 'accepted'}}
expect(res.statusCode).toBe(403)
patch = yield Patch.findById(@patchID)
expect(patch.get('status')).toBe('pending')
done()
it 'allows the recipient to accept or reject the pull request', utils.wrap (done) ->
yield utils.loginUser(@creator)
[res, body] = yield request.putAsync {@url, json: {status: 'rejected'}}
expect(res.statusCode).toBe(200)
patch = yield Patch.findById(@patchID)
expect(patch.get('status')).toBe 'rejected'
[res, body] = yield request.putAsync {@url, json: {status: 'accepted'}}
expect(body.status).toBe('accepted')
expect(body.acceptor).toBe(@creator.id)
done()
it 'keeps track of amount of submitted and accepted patches', utils.wrap (done) ->
yield utils.loginUser(@creator)
[res, body] = yield request.putAsync {@url, json: {status: 'accepted'}}
expect(res.statusCode).toBe(200)
yield new Promise((resolve) -> setTimeout(resolve, 100))
user = yield User.findById(@user.id)
expect(user.get 'stats.patchesSubmitted').toBe 1
expect(user.get 'stats.patchesContributed').toBe 1
expect(user.get 'stats.totalMiscPatches').toBe 1
expect(user.get 'stats.articleMiscPatches').toBe 1
expect(user.get 'stats.totalTranslationPatches').toBeUndefined()
done()
it 'does not allow the recipient to withdraw the pull request', utils.wrap (done) ->
yield utils.loginUser(@creator)
[res, body] = yield request.putAsync {@url, json: {status: 'withdrawn'}}
expect(res.statusCode).toBe(403)
done()
it 'only allows artisans and admins to set patch status for courses', utils.wrap (done) ->
submitter = yield utils.initUser()

View file

@ -1,6 +1,7 @@
GLOBAL._ = require 'lodash'
User = require '../../../server/models/User'
utils = require '../utils'
describe 'User', ->
@ -65,3 +66,13 @@ describe 'User', ->
[timestamp, hash] = code.split(':')
expect(new Date(parseInt(timestamp))).toEqual(now)
done()
describe '.incrementStatAsync()', ->
it 'records nested stats', utils.wrap (done) ->
user = yield utils.initUser()
yield User.incrementStatAsync user.id, 'stats.testNumber'
yield User.incrementStatAsync user.id, 'stats.concepts.basic', {inc: 10}
user = yield User.findById(user.id)
expect(user.get('stats.testNumber')).toBe(1)
expect(user.get('stats.concepts.basic')).toBe(10)
done()

View file

@ -0,0 +1,51 @@
User = require '../../../server/models/User'
request = require '../request'
utils = require '../utils'
Promise = require 'bluebird'
UserHandler = require '../../../server/handlers/user_handler'
describe 'UserHandler', ->
describe '.recalculateStats(statName, done)', ->
it 'recalculates amount of submitted and accepted patches', utils.wrap (done) ->
creator = yield utils.initAdmin()
yield utils.loginUser(creator)
article = yield utils.makeArticle()
user = yield utils.initUser()
yield utils.loginUser(user)
json = {
commitMessage: 'Accept this patch!'
delta: {name: ['test']}
target: {id: article.id, collection: 'article'}
}
url = utils.getURL('/db/patch')
[res, body] = yield request.postAsync { url, json }
patchID = body._id
expect(res.statusCode).toBe(201)
yield utils.loginUser(creator)
url = utils.getURL("/db/patch/#{patchID}/status")
[res, body] = yield request.putAsync {url, json: {status: 'accepted'}}
expect(res.statusCode).toBe(200)
user = yield User.findById(user.id)
expect(user.get 'stats.patchesSubmitted').toBe 1
statsBefore = user.get('stats')
yield user.update({$unset: {stats: ''}})
user = yield User.findById(user.id)
expect(user.get 'stats').toBeUndefined()
recalculateStatsAsync = Promise.promisify(UserHandler.recalculateStats)
yield [
recalculateStatsAsync 'patchesContributed'
recalculateStatsAsync 'patchesSubmitted'
recalculateStatsAsync 'totalMiscPatches'
recalculateStatsAsync 'totalTranslationPatches'
recalculateStatsAsync 'articleMiscPatches'
]
user = yield User.findById(user.id)
statsAfter = user.get('stats')
expect(_.isEqual(statsBefore, statsAfter)).toBe(true)
done()

View file

@ -2,6 +2,11 @@ async = require 'async'
utils = require '../../server/lib/utils'
co = require 'co'
Promise = require 'bluebird'
Article = require '../../server/models/Article'
LevelComponent = require '../../server/models/LevelComponent'
LevelSystem = require '../../server/models/LevelSystem'
Poll = require '../../server/models/Poll'
ThangType = require '../../server/models/ThangType'
User = require '../../server/models/User'
Level = require '../../server/models/Level'
LevelSession = require '../../server/models/LevelSession'
@ -132,6 +137,78 @@ module.exports = mw =
session = new LevelSession(data)
session.save(done)
makeArticle: Promise.promisify (data, sources, done) ->
args = Array.from(arguments)
[done, [data, sources]] = [args.pop(), args]
data = _.extend({}, {
name: _.uniqueId('Article ')
}, data)
request.post { uri: getURL('/db/article'), json: data }, (err, res) ->
return done(err) if err
expect(res.statusCode).toBe(201)
Article.findById(res.body._id).exec done
makeLevelComponent: Promise.promisify (data, sources, done) ->
args = Array.from(arguments)
[done, [data, sources]] = [args.pop(), args]
data = _.extend({}, {
name: _.uniqueId('LevelComponent')
system: 'ai'
code: 'let const = var'
permissions: [{target: mw.lastLogin.id, access: 'owner'}]
}, data)
request.post { uri: getURL('/db/level.component'), json: data }, (err, res) ->
return done(err) if err
expect(res.statusCode).toBe(200)
LevelComponent.findById(res.body._id).exec done
makeLevelSystem: Promise.promisify (data, sources, done) ->
args = Array.from(arguments)
[done, [data, sources]] = [args.pop(), args]
data = _.extend({}, {
name: _.uniqueId('LevelSystem')
permissions: [{target: mw.lastLogin.id, access: 'owner'}]
code: 'let const = var'
}, data)
request.post { uri: getURL('/db/level.system'), json: data }, (err, res) ->
return done(err) if err
expect(res.statusCode).toBe(200)
LevelSystem.findById(res.body._id).exec done
makePoll: Promise.promisify (data, sources, done) ->
args = Array.from(arguments)
[done, [data, sources]] = [args.pop(), args]
data = _.extend({}, {
name: _.uniqueId('Poll ')
permissions: [{target: mw.lastLogin.id, access: 'owner'}]
}, data)
request.post { uri: getURL('/db/poll'), json: data }, (err, res) ->
return done(err) if err
expect(res.statusCode).toBe(200)
Poll.findById(res.body._id).exec done
makeThangType: Promise.promisify (data, sources, done) ->
args = Array.from(arguments)
[done, [data, sources]] = [args.pop(), args]
data = _.extend({}, {
name: _.uniqueId('Thang Type ')
permissions: [{target: mw.lastLogin.id, access: 'owner'}]
}, data)
request.post { uri: getURL('/db/thang.type'), json: data }, (err, res) ->
return done(err) if err
expect(res.statusCode).toBe(200)
ThangType.findById(res.body._id).exec done
makeAchievement: Promise.promisify (data, sources, done) ->
args = Array.from(arguments)
[done, [data, sources]] = [args.pop(), args]