Made the server side of the patch system.

This commit is contained in:
Scott Erickson 2014-04-08 19:26:19 -07:00
parent ff73aecf08
commit 2f988ba485
22 changed files with 317 additions and 25 deletions

View file

@ -1,12 +1,11 @@
mongoose = require('mongoose')
plugins = require('../plugins/plugins')
ArticleSchema = new mongoose.Schema(
body: String,
)
ArticleSchema = new mongoose.Schema(body: String, {strict:false})
ArticleSchema.plugin(plugins.NamedPlugin)
ArticleSchema.plugin(plugins.VersionedPlugin)
ArticleSchema.plugin(plugins.SearchablePlugin, {searchable: ['body', 'name']})
ArticleSchema.plugin(plugins.PatchablePlugin)
module.exports = mongoose.model('article', ArticleSchema)

View file

@ -9,4 +9,8 @@ ArticleHandler = class ArticleHandler extends Handler
hasAccess: (req) ->
req.method is 'GET' or req.user?.isAdmin()
hasAccessToDocument: (req, document, method=null) ->
return true if req.method is 'GET' or method is 'get' or req.user?.isAdmin()
return false
module.exports = new ArticleHandler()

View file

@ -6,8 +6,9 @@ c.extendNamedProperties ArticleSchema # name first
ArticleSchema.properties.body = { type: 'string', title: 'Content', format: 'markdown' }
ArticleSchema.properties.i18n = { type: 'object', title: 'i18n', format: 'i18n', props: ['body'] }
c.extendBasicProperties(ArticleSchema, 'article')
c.extendSearchableProperties(ArticleSchema)
c.extendVersionedProperties(ArticleSchema, 'article')
c.extendBasicProperties ArticleSchema, 'article'
c.extendSearchableProperties ArticleSchema
c.extendVersionedProperties ArticleSchema, 'article'
c.extendPatchableProperties ArticleSchema
module.exports = ArticleSchema

View file

@ -3,6 +3,7 @@ mongoose = require('mongoose')
Grid = require 'gridfs-stream'
errors = require './errors'
log = require 'winston'
Patch = require '../patches/Patch'
PROJECT = {original:1, name:1, version:1, description: 1, slug:1, kind: 1}
FETCH_LIMIT = 200
@ -27,8 +28,7 @@ module.exports = class Handler
getEditableProperties: (req, document) ->
props = @editableProperties.slice()
isBrandNew = req.method is 'POST' and not req.body.original
if isBrandNew
props = props.concat @postEditableProperties
props = props.concat @postEditableProperties if isBrandNew
if @modelClass.schema.uses_coco_permissions
# can only edit permissions if this is a brand new property,
@ -37,8 +37,8 @@ module.exports = class Handler
if isBrandNew or isOwner or req.user?.isAdmin()
props.push 'permissions'
if @modelClass.schema.uses_coco_versions
props.push 'commitMessage'
props.push 'commitMessage' if @modelClass.schema.uses_coco_versions
props.push 'allowPatches' if @modelClass.schema.is_patchable
props
@ -93,8 +93,32 @@ module.exports = class Handler
getByRelationship: (req, res, args...) ->
# this handler should be overwritten by subclasses
if @modelClass.schema.is_patchable
return @getPatchesFor(req, res, args[0]) if req.route.method is 'get' and args[1] is 'patches'
return @setListening(req, res, args[0]) if req.route.method is 'put' and args[1] is 'listen'
return @sendNotFoundError(res)
getPatchesFor: (req, res, id) ->
query = { 'target.original': mongoose.Types.ObjectId(id), status: req.query.status or 'pending' }
Patch.find(query).sort('-created').exec (err, patches) =>
return @sendDatabaseError(res, err) if err
patches = (patch.toObject() for patch in patches)
@sendSuccess(res, patches)
setListening: (req, res, id) ->
@getDocumentForIdOrSlug id, (err, document) =>
return @sendUnauthorizedError(res) unless @hasAccessToDocument(req, document, 'get')
return @sendDatabaseError(res, err) if err
return @sendNotFoundError(res) unless document?
listeners = document.get('listeners') or []
me = req.user.get('_id')
listeners = (l for l in listeners when not l.equals(me))
listeners.push me if req.body.on
document.set 'listeners', listeners
document.save (err, document) =>
return @sendDatabaseError(res, err) if err
@sendSuccess(res, @formatEntity(req, document))
search: (req, res) ->
unless @modelClass.schema.uses_coco_search
return @sendNotFoundError(res)
@ -224,6 +248,7 @@ module.exports = class Handler
document.set('original', document._id)
document.set('creator', req.user._id)
@saveChangesToDocument req, document, (err) =>
console.log 'saved new version', document.toObject()
return @sendBadInputError(res, err.errors) if err?.valid is false
return @sendDatabaseError(res, err) if err
@sendSuccess(res, @formatEntity(req, document))

View file

@ -59,6 +59,9 @@ patchableProps = ->
_id: me.objectId(links: [{rel: "db", href: "/db/patch/{($)}"}], title: "Patch ID", description: "A reference to the patch.")
status: { enum: ['pending', 'accepted', 'rejected', 'cancelled']}
})
allowPatches: { type: 'boolean' }
listeners: me.array({title:'Listeners'},
me.objectId(links: [{rel: 'extra', href: "/db/user/{($)}"}]))
me.extendPatchableProperties = (schema) ->
schema.properties = {} unless schema.properties?

View file

@ -10,6 +10,7 @@ LevelSchema.plugin(plugins.NamedPlugin)
LevelSchema.plugin(plugins.PermissionsPlugin)
LevelSchema.plugin(plugins.VersionedPlugin)
LevelSchema.plugin(plugins.SearchablePlugin, {searchable: ['name', 'description']})
LevelSchema.plugin(plugins.PatchablePlugin)
LevelSchema.pre 'init', (next) ->
return next() unless jsonschema.properties?

View file

@ -7,10 +7,11 @@ LevelComponentSchema = new mongoose.Schema {
system: String
}, {strict: false}
LevelComponentSchema.plugin(plugins.NamedPlugin)
LevelComponentSchema.plugin(plugins.PermissionsPlugin)
LevelComponentSchema.plugin(plugins.VersionedPlugin)
LevelComponentSchema.plugin(plugins.SearchablePlugin, {searchable: ['name', 'description', 'system']})
LevelComponentSchema.plugin plugins.NamedPlugin
LevelComponentSchema.plugin plugins.PermissionsPlugin
LevelComponentSchema.plugin plugins.VersionedPlugin
LevelComponentSchema.plugin plugins.SearchablePlugin, {searchable: ['name', 'description', 'system']}
LevelComponentSchema.plugin plugins.PatchablePlugin
LevelComponentSchema.pre 'init', (next) ->
return next() unless jsonschema.properties?

View file

@ -115,5 +115,6 @@ c.extendBasicProperties LevelComponentSchema, 'level.component'
c.extendSearchableProperties LevelComponentSchema
c.extendVersionedProperties LevelComponentSchema, 'level.component'
c.extendPermissionsProperties LevelComponentSchema, 'level.component'
c.extendPatchableProperties LevelComponentSchema
module.exports = LevelComponentSchema

View file

@ -38,8 +38,7 @@ LevelHandler = class LevelHandler extends Handler
return @getLeaderboardFacebookFriends(req, res, args[0]) if args[1] is 'leaderboard_facebook_friends'
return @getLeaderboardGPlusFriends(req, res, args[0]) if args[1] is 'leaderboard_gplus_friends'
return @getHistogramData(req, res, args[0]) if args[1] is 'histogram_data'
return @sendNotFoundError(res)
super(arguments...)
fetchLevelByIDAndHandleErrors: (id, req, res, callback) ->
@getDocumentForIdOrSlug id, (err, level) =>

View file

@ -243,6 +243,7 @@ c.extendBasicProperties LevelSchema, 'level'
c.extendSearchableProperties LevelSchema
c.extendVersionedProperties LevelSchema, 'level'
c.extendPermissionsProperties LevelSchema, 'level'
c.extendPatchableProperties LevelSchema
module.exports = LevelSchema

View file

@ -13,7 +13,7 @@ class LevelSessionHandler extends Handler
getByRelationship: (req, res, args...) ->
return @getActiveSessions req, res if args.length is 2 and args[1] is 'active'
return @sendNotFoundError(res)
super(arguments...)
getActiveSessions: (req, res) ->
return @sendUnauthorizedError(res) unless req.user.isAdmin()

View file

@ -10,6 +10,7 @@ LevelSystemSchema.plugin(plugins.NamedPlugin)
LevelSystemSchema.plugin(plugins.PermissionsPlugin)
LevelSystemSchema.plugin(plugins.VersionedPlugin)
LevelSystemSchema.plugin(plugins.SearchablePlugin, {searchable: ['name', 'description']})
LevelSystemSchema.plugin(plugins.PatchablePlugin)
LevelSystemSchema.pre 'init', (next) ->
return next() unless jsonschema.properties?

View file

@ -101,6 +101,7 @@ _.extend LevelSystemSchema.properties,
c.extendBasicProperties LevelSystemSchema, 'level.system'
c.extendSearchableProperties LevelSystemSchema
c.extendVersionedProperties LevelSystemSchema, 'level.system'
c.extendPermissionsProperties LevelSystemSchema, 'level.system'
c.extendPermissionsProperties LevelSystemSchema
c.extendPatchableProperties LevelSystemSchema
module.exports = LevelSystemSchema

View file

@ -5,8 +5,9 @@ ThangTypeSchema = new mongoose.Schema({
body: String,
}, {strict: false})
ThangTypeSchema.plugin(plugins.NamedPlugin)
ThangTypeSchema.plugin(plugins.VersionedPlugin)
ThangTypeSchema.plugin(plugins.SearchablePlugin, {searchable: ['name']})
ThangTypeSchema.plugin plugins.NamedPlugin
ThangTypeSchema.plugin plugins.VersionedPlugin
ThangTypeSchema.plugin plugins.SearchablePlugin, {searchable: ['name']}
ThangTypeSchema.plugin plugins.PatchablePlugin
module.exports = mongoose.model('thang.type', ThangTypeSchema)

View file

@ -146,8 +146,9 @@ ThangTypeSchema.definitions =
action: ActionSchema
sound: SoundSchema
c.extendBasicProperties(ThangTypeSchema, 'thang.type')
c.extendSearchableProperties(ThangTypeSchema)
c.extendVersionedProperties(ThangTypeSchema, 'thang.type')
c.extendBasicProperties ThangTypeSchema, 'thang.type'
c.extendSearchableProperties ThangTypeSchema
c.extendVersionedProperties ThangTypeSchema, 'thang.type'
c.extendPatchableProperties ThangTypeSchema
module.exports = ThangTypeSchema

View file

@ -0,0 +1,47 @@
mongoose = require('mongoose')
{handlers} = require '../commons/mapping'
PatchSchema = new mongoose.Schema({}, {strict: false})
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')
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
handler = require('../' + handlers[collection])
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.push @_id
console.log 'PATCH PUSHED', @_id
document.set 'patches', patches
document.save (err) -> next(err)
module.exports = mongoose.model('patch', PatchSchema)

View file

@ -0,0 +1,55 @@
Patch = require('./Patch')
Handler = require('../commons/Handler')
schema = require './patch_schema'
{handlers} = require '../commons/mapping'
mongoose = require('mongoose')
PatchHandler = class PatchHandler extends Handler
modelClass: Patch
editableProperties: []
postEditableProperties: ['delta', 'target']
jsonSchema: require './patch_schema'
makeNewInstance: (req) ->
patch = super(req)
patch.set 'creator', req.user._id
patch.set 'created', new Date().toISOString()
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...)
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 = { 'original': 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 @sendUnauthorizedError(res) unless targetHandler.hasAccessToDocument(req, target, 'get')
if newStatus in ['rejected', 'accepted']
return @sendUnauthorizedError(res) unless targetHandler.hasAccessToDocument(req, target, 'put')
if newStatus is 'withdrawn'
return @sendUnauthorizedError(res) unless req.user.get('_id').equals patch.get('creator')
# these require callbacks
patch.update {$set:{status:newStatus}}, {}, ->
target.update {$pull:{patches:patch.get('_id')}}, {}, ->
@sendSuccess(res, null)
module.exports = new PatchHandler()

View file

@ -0,0 +1,28 @@
c = require '../commons/schemas'
patchables = ['level', 'thang_type', 'level_system', 'level_component', 'article']
PatchSchema = c.object({title:'Patch', required:['target', 'delta']}, {
delta: { title: 'Delta', type:['array', 'object'] }
title: c.shortString()
description: c.shortString({maxLength: 500})
creator: c.objectId(links: [{rel: 'extra', href: "/db/user/{($)}"}])
created: c.date( { title: 'Created', readOnly: true })
status: { enum: ['pending', 'accepted', 'rejected', 'withdrawn']}
target: c.object({title: 'Target', required:['collection', 'id']}, {
collection: { enum: patchables }
id: c.objectId(title: 'Target ID') # search by this if not versioned
# if target is versioned, want to know that info too
original: c.objectId(title: 'Target Original') # search by this if versioned
version:
properties:
major: { type: 'number', minimum: 0 }
minor: { type: 'number', minimum: 0 }
})
})
c.extendBasicProperties(PatchSchema, 'patch')
module.exports = PatchSchema

View file

@ -2,6 +2,10 @@ mongoose = require('mongoose')
User = require('../users/User')
textSearch = require('mongoose-text-search')
module.exports.PatchablePlugin = (schema) ->
schema.is_patchable = true
schema.index({'target.original':1, 'status':'1', 'created':-1})
module.exports.NamedPlugin = (schema) ->
schema.add({name: String, slug: String})
schema.index({'slug': 1}, {unique: true, sparse: true, name: 'slug index'})

View file

@ -171,7 +171,7 @@ UserHandler = class UserHandler extends Handler
return @getNamesByIds(req, res) if args[1] is 'names'
return @nameToID(req, res, args[0]) if args[1] is 'nameToID'
return @getLevelSessions(req, res, args[0]) if args[1] is 'level.sessions'
return @sendNotFoundError(res)
super(arguments...)
agreeToCLA: (req, res) ->
return @sendUnauthorizedError(res) unless req.user

View file

@ -10,6 +10,7 @@ _.mixin(_.str.exports())
GLOBAL.mongoose = require 'mongoose'
mongoose.connect('mongodb://localhost/coco_unittest')
path = require('path')
GLOBAL.testing = true
models_path = [
'../../server/articles/Article'
@ -19,6 +20,7 @@ models_path = [
'../../server/levels/sessions/LevelSession'
'../../server/levels/thangs/LevelThangType'
'../../server/users/User'
'../../server/patches/Patch'
]
for m in models_path

View file

@ -0,0 +1,117 @@
require '../common'
describe '/db/patch', ->
request = require 'request'
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 =
delta: {name:['test']}
target:
id:null
collection: 'article'
it 'creates an Article to patch', (done) ->
loginAdmin ->
request.post {uri:articleURL, json:patch}, (err, res, body) ->
articles[0] = body
patch.target.id = articles[0]._id
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()
it 'adds a patch to the target document', (done) ->
Article.findOne({}).exec (err, article) ->
expect(article.toObject().patches[0]).toBeDefined()
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 'allows you to set yourself as listening', (done) ->
listeningURL = getURL("/db/article/#{articles[0]._id}/listen")
request.put {uri: listeningURL, json: {on:true}}, (err, res, body) ->
expect(body.listeners[0]).toBeDefined()
done()
it 'added the listener to the target document', (done) ->
Article.findOne({}).exec (err, article) ->
expect(article.toObject().listeners[0]).toBeDefined()
done()
it 'does not add duplicate listeners', (done) ->
listeningURL = getURL("/db/article/#{articles[0]._id}/listen")
request.put {uri: listeningURL, json: {on:true}}, (err, res, body) ->
expect(body.listeners.length).toBe(1)
done()
it 'allows removing yourself', (done) ->
listeningURL = getURL("/db/article/#{articles[0]._id}/listen")
request.put {uri: listeningURL, json: {on:false}}, (err, res, body) ->
expect(body.listeners.length).toBe(0)
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()
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 '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'
done()
it 'does not allow the recipient 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(403)
Patch.findOne({}).exec (err, article) ->
expect(article.get('status')).toBe 'accepted'
done()