Added admin/pending-patches view. Fixed accepting several kinds of patches. Added keyboard shortcuts for accepting (a) and rejecting (r) patches. Fixed #2490. Fixed #2515. Fixed #2304.

This commit is contained in:
Nick Winter 2015-03-28 13:54:44 -07:00
parent 37d7b4661c
commit 0b1bb6a4aa
23 changed files with 269 additions and 17 deletions

View file

@ -39,6 +39,7 @@ module.exports = class CocoRouter extends Backbone.Router
'admin/users': go('admin/UsersView')
'admin/base': go('admin/BaseView')
'admin/user-code-problems': go('admin/UserCodeProblemsView')
'admin/pending-patches': go('admin/PendingPatchesView')
'beta': go('HomeView')

View file

@ -5,3 +5,44 @@ module.exports = class Poll extends CocoModel
@className: 'Poll'
@schema: schema
urlRoot: '/db/poll'
applyDelta: (delta) ->
# Hackiest hacks ever, just manually mauling the delta (whose format I don't understand) to not overwrite votes and other languages' nested translations.
# One still must be careful about patches that accidentally delete keys from the top-level i18n object.
i18nDelta = {}
if delta.i18n
i18nDelta.i18n = $.extend true, {}, delta.i18n
for answerIndex, answerChanges of delta.answers ? {}
i18nDelta.answers ?= {}
if _.isArray answerChanges
i18nDelta.answers[answerIndex] ?= []
for change in answerChanges
if _.isNumber change
pickedChange = change
else
pickedChange = $.extend true, {}, change
for key of pickedChange
answerIndexNum = parseInt(answerIndex.replace('_', ''), 10)
unless _.isNaN answerIndexNum
oldValue = @get('answers')[answerIndexNum][key]
isDeletion = _.string.startsWith answerIndex, '_'
isI18N = key is 'i18n'
if isI18N and not isDeletion
# Use the new change, but make sure we're not deleting any other languages' translations.
value = pickedChange[key]
for language, oldTranslations of oldValue ? {}
for translationKey, translationValue of oldTranslations ? {}
value[language] ?= {}
value[language][translationKey] ?= translationValue
else
value = oldValue
pickedChange[key] = value
i18nDelta.answers[answerIndex].push pickedChange
else
i18nDelta.answers[answerIndex] = answerChanges
if answerChanges?.votes
i18nDelta.answers[answerIndex] = _.omit answerChanges, 'votes'
#console.log 'got delta', delta
#console.log 'got i18nDelta', i18nDelta
super i18nDelta

View file

@ -21,3 +21,9 @@
bottom: 0
right: 0
width: 75%
.patches-view
position: absolute
left: 20px
top: 20px
z-index: 30

View file

@ -2,5 +2,5 @@
.status-buttons
margin-bottom: 10px
.patch-icon
.patch-row
cursor: pointer

View file

@ -43,6 +43,8 @@ block content
a(href="/admin/base", data-i18n="admin.av_other_debug_base_url") Base (for debugging base.jade)
li
a(href="/admin/clas", data-i18n="admin.clas") CLAs
li
a(href="/admin/pending-patches", data-i18n="resources.patches") Patches
if me.isAdmin()
li Analytics
ul

View file

@ -0,0 +1,20 @@
extends /templates/base
block content
h1(data-i18n="resources.patches") Patches
table.table.table-striped.table-bordered.table-condensed#patches
tbody
each patch in patches
tr
td #{patch.target.collection}
td
if patch.url
a(href=patch.url)= patch.name
else
span= patch.target.original
td #{patch.creatorName}
td #{patch.commitMessage}

View file

@ -33,6 +33,11 @@ block header
a(data-toggle="coco-modal", data-target="modal/RevertModal", data-i18n="editor.revert")#revert-button Revert
li(class=anonymous ? "disabled": "")
a(data-i18n="editor.pop_i18n")#pop-level-i18n-button Populate i18n
if me.isAdmin()
li#patches-button
a
span.spr.glyphicon-wrench.glyphicon
span(data-i18n="resources.patches") Patches
li.divider
li.dropdown-header(data-i18n="common.info") Info
@ -44,5 +49,6 @@ block outer_content
#right-column
#campaign-view
#campaign-level-view.hidden
.patches-view.hidden
block footer

View file

@ -20,11 +20,8 @@ else
th(data-i18n="general.submitter") Submitter
th(data-i18n="general.submitted") Submitted
th(data-i18n="general.commit_msg") Commit Message
th(data-i18n="general.review") Review
for patch in patches
tr
tr.patch-row(data-patch-id=patch.id)
td= patch.userName
td= moment(patch.get('created')).format('llll')
td= patch.get('commitMessage')
td
span.glyphicon.glyphicon-wrench(data-patch-id=patch.id).patch-icon

View file

@ -0,0 +1,110 @@
RootView = require 'views/core/RootView'
template = require 'templates/admin/pending-patches-view'
CocoCollection = require 'collections/CocoCollection'
Patch = require 'models/Patch'
class PendingPatchesCollection extends CocoCollection
url: '/db/patch?view=pending'
model: Patch
module.exports = class PatchesView extends RootView
id: 'pending-patches-view'
template: template
constructor: (options) ->
super options
@nameMap = {}
@patches = @supermodel.loadCollection(new PendingPatchesCollection(), 'patches', {cache: false}).model
onLoaded: ->
super()
@loadUserNames()
@loadAllModelNames()
getRenderData: ->
c = super()
c.patches = []
if @supermodel.finished()
comparator = (m) -> m.target.collection + ' ' + m.target.original
patches = _.sortBy (_.clone(patch.attributes) for patch in @patches.models), comparator
c.patches = _.uniq patches, comparator
for patch in c.patches
patch.creatorName = @nameMap[patch.creator] or patch.creator
if name = @nameMap[patch.target.original]
patch.name = name
patch.slug = _.string.slugify name
patch.url = '/editor/' + switch patch.target.collection
when 'level', 'achievement', 'article', 'campaign', 'poll'
"#{patch.target.collection}/#{patch.slug}"
when 'thang_type'
"thang/#{patch.slug}"
when 'level_system', 'level_component'
"level/items?#{patch.target.collection}=#{patch.slug}"
else
console.log "Where do we review a #{patch.target.collection} patch?"
''
c
loadUserNames: ->
# Only fetch the names for the userIDs we don't already have in @nameMap
ids = []
for patch in @patches.models
unless id = patch.get('creator')
console.error 'Found bad user ID in malformed patch', patch
continue
ids.push id unless @nameMap[id]
ids = _.uniq ids
return unless ids.length
success = (nameMap) =>
return if @destroyed
for patch in @patches.models
creatorID = patch.get 'creator'
continue if @nameMap[creatorID]
creator = nameMap[creatorID]
name = creator?.name
name ||= creator.firstName + ' ' + creator.lastName if creator?.firstName
name ||= "Anonymous #{creatorID.substr(18)}" if creator
name ||= '<bad patch data>'
if name.length > 21
name = name.substr(0, 18) + '...'
@nameMap[creatorID] = name
@render()
userNamesRequest = @supermodel.addRequestResource 'user_names', {
url: '/db/user/-/names'
data: {ids: ids}
method: 'POST'
success: success
}, 0
userNamesRequest.load()
loadAllModelNames: ->
allPatches = (p.attributes for p in @patches.models)
allPatches = _.groupBy allPatches, (p) -> p.target.collection
@loadCollectionModelNames collection, patches for collection, patches of allPatches
loadCollectionModelNames: (collection, patches) ->
ids = (patch.target.original for patch in patches when not @nameMap[patch.target.original])
ids = _.uniq ids
return unless ids.length
success = (nameMapArray) =>
return if @destroyed
nameMap = {}
for model in nameMapArray
nameMap[model.original or model._id] = model.name
for patch in patches
original = patch.target.original
name = nameMap[original]
if name and name.length > 60
name = name.substr(0, 57) + '...'
@nameMap[original] = name
@render()
modelNamesRequest = @supermodel.addRequestResource 'patches', {
url: "/db/#{collection}/names"
data: {ids: ids}
method: 'POST'
success: success
}, 0
modelNamesRequest.load()

View file

@ -9,12 +9,17 @@ module.exports = class PatchModal extends ModalView
template: template
plain: true
modalWidthPercent: 60
instant: true
events:
'click #withdraw-button': 'withdrawPatch'
'click #reject-button': 'rejectPatch'
'click #accept-button': 'acceptPatch'
shortcuts:
'a': 'acceptPatch'
'r': 'rejectPatch'
constructor: (@patch, @targetModel, options) ->
super(options)
targetID = @patch.get('target').id

View file

@ -11,7 +11,7 @@ module.exports = class PatchesView extends CocoView
events:
'change .status-buttons': 'onStatusButtonsChanged'
'click .patch-icon': 'openPatchModal'
'click .patch-row': 'openPatchModal'
constructor: (@model, options) ->
super(options)
@ -51,8 +51,8 @@ module.exports = class PatchesView extends CocoView
@render()
openPatchModal: (e) ->
console.log 'open patch modal'
patch = _.find @patches.models, {id: $(e.target).data('patch-id')}
row = $(e.target).closest '.patch-row'
patch = _.find @patches.models, {id: row.data('patch-id')}
modal = new PatchModal(patch, @model)
@openModalView(modal)
@listenTo modal, 'accepted-patch', -> @trigger 'accepted-patch'

View file

@ -28,6 +28,9 @@ module.exports = class AchievementEditView extends RootView
onLoaded: ->
super()
@buildTreema()
@listenTo @achievement, 'change', =>
@achievement.updateI18NCoverage()
@treema.set('/', @achievement.attributes)
buildTreema: ->
return if @treema? or (not @achievement.loaded)

View file

@ -29,6 +29,9 @@ module.exports = class ArticleEditView extends RootView
onLoaded: ->
super()
@buildTreema()
@listenTo @article, 'change', =>
@article.updateI18NCoverage()
@treema.set('/', @article.attributes)
buildTreema: ->
return if @treema? or (not @article.loaded)

View file

@ -11,6 +11,7 @@ RelatedAchievementsCollection = require 'collections/RelatedAchievementsCollecti
CampaignAnalyticsModal = require './CampaignAnalyticsModal'
CampaignLevelView = require './CampaignLevelView'
SaveCampaignModal = require './SaveCampaignModal'
PatchesView = require 'views/editor/PatchesView'
achievementProject = ['related', 'rewards', 'name', 'slug']
thangTypeProject = ['name', 'original']
@ -23,6 +24,7 @@ module.exports = class CampaignEditorView extends RootView
events:
'click #analytics-button': 'onClickAnalyticsButton'
'click #save-button': 'onClickSaveButton'
'click #patches-button': 'onClickPatches'
subscriptions:
'editor:campaign-analytics-modal-closed' : 'onAnalyticsModalClosed'
@ -31,6 +33,9 @@ module.exports = class CampaignEditorView extends RootView
super(options)
@campaign = new Campaign({_id:@campaignHandle})
@supermodel.loadModel(@campaign, 'campaign')
@listenToOnce @campaign, 'sync', (model, response, jqXHR) ->
@campaign.set '_id', response._id
@campaign.url = -> '/db/campaign/' + @id
# Save reference to data used by anlytics modal so it persists across modal open/closes.
@campaignAnalytics = {}
@ -140,6 +145,11 @@ module.exports = class CampaignEditorView extends RootView
c.campaign = @campaign
c
onClickPatches: (e) ->
@patchesView = @insertSubView(new PatchesView(@campaign), @$el.find('.patches-view'))
@patchesView.load()
@patchesView.$el.removeClass 'hidden'
onClickAnalyticsButton: ->
@openModalView new CampaignAnalyticsModal {}, @campaignHandle, @campaignAnalytics
@ -183,6 +193,11 @@ module.exports = class CampaignEditorView extends RootView
@listenTo @campaignView, 'adjacent-campaign-moved', @onAdjacentCampaignMoved
@listenTo @campaignView, 'level-clicked', @onCampaignLevelClicked
@listenTo @campaignView, 'level-double-clicked', @onCampaignLevelDoubleClicked
@listenTo @campaign, 'change:i18n', =>
@campaign.updateI18NCoverage()
@treema.set('/i18n', @campaign.get('i18n'))
@treema.set('/i18nCoverage', @campaign.get('i18nCoverage'))
@insertSubView @campaignView
onTreemaChanged: (e, nodes) =>

View file

@ -40,6 +40,9 @@ module.exports = class PollEditView extends RootView
onLoaded: ->
super()
@buildTreema()
@listenTo @poll, 'change', =>
@poll.updateI18NCoverage()
@treema.set('/', @poll.attributes)
buildTreema: ->
return if @treema? or (not @poll.loaded)

View file

@ -31,6 +31,7 @@ module.exports = class MyMatchesTabView extends CocoView
continue
ids.push id unless @nameMap[id]
ids = _.uniq ids
return unless ids.length
success = (nameMap) =>

View file

@ -7,12 +7,18 @@ for(var i in patches) {
if(patch.target.collection === 'level') collection = db.levels;
if(patch.target.collection === 'level_component') collection = db.level.components;
if(patch.target.collection === 'level_system') collection = db.level.systems;
if(patch.target.collection === 'thang_type') collection = db.level.thang.types;
if(patch.target.collection === 'thang_type') collection = db.thang.types;
if(patch.target.collection === 'achievement') collection = db.achievements;
if(patch.target.collection === 'article') collection = db.articles;
if(patch.target.collection === 'campaign') collection = db.campaigns;
if(patch.target.collection === 'poll') collection = db.polls;
if(collection === null) {
print('could not find collection', patch.target.collection);
continue;
}
var target = collection.findOne({original:patch.target.original, name:{$exists:true}});
if(target === null)
target = collection.findOne({_id:ObjectId(patch.target.original), name:{$exists:true}});
var creator = db.users.findOne({_id:patch.creator});
if(target === null) {
print('No target for patch from', patch.target.collection);
@ -23,5 +29,11 @@ for(var i in patches) {
continue;
}
print(target.name, 'made by', creator.name);
var editor = patch.target.collection + '/';
if(editor === 'level_component/' || editor === 'level_system/')
editor = 'level/items?' + patch.target.collection + '=';
if(editor === 'thang_type/')
editor = 'thang/';
var url = 'http://localhost:3000/editor/' + editor + target.slug;
print(url + '\t' + creator.name + '\t' + target.name);
}

View file

@ -57,4 +57,6 @@ class AchievementHandler extends Handler
return @sendDatabaseError(res, err) if err
@sendNoContent res
getNamesByIDs: (req, res) -> @getNamesByOriginals req, res, true
module.exports = new AchievementHandler()

View file

@ -100,4 +100,6 @@ CampaignHandler = class CampaignHandler extends Handler
docLink = "http://codecombat.com#{req.headers['x-current-path']}"
@sendChangedHipChatMessage creator: req.user, target: doc, docLink: docLink
getNamesByIDs: (req, res) -> @getNamesByOriginals req, res, true
module.exports = new CampaignHandler()

View file

@ -228,7 +228,7 @@ module.exports = class Handler
return @getNamesByOriginals(req, res)
@getPropertiesFromMultipleDocuments res, User, 'name', ids
getNamesByOriginals: (req, res) ->
getNamesByOriginals: (req, res, nonVersioned=false) ->
ids = req.query.ids or req.body.ids
ids = ids.split(',') if _.isString ids
ids = _.uniq ids
@ -236,11 +236,12 @@ module.exports = class Handler
# Hack: levels loading thang types need the components returned as well.
# Need a way to specify a projection for a query.
project = {name: 1, original: 1, kind: 1, components: 1}
sort = {'version.major':-1, 'version.minor':-1}
sort = if nonVersioned then {} else {'version.major': -1, 'version.minor': -1}
makeFunc = (id) =>
(callback) =>
criteria = {original:mongoose.Types.ObjectId(id)}
criteria = {}
criteria[if nonVersioned then '_id' else 'original'] = mongoose.Types.ObjectId(id)
@modelClass.findOne(criteria, project).sort(sort).exec (err, document) ->
return done(err) if err
callback(null, document?.toObject() or null)

View file

@ -25,6 +25,17 @@ PatchHandler = class PatchHandler extends Handler
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'
q = Patch.find(query)
q.exec (err, documents) =>
return @sendDatabaseError(res, err) if err
documents = (@formatEntity(req, doc) for doc in documents)
@sendSuccess(res, documents)
else
super(arguments...)
setStatus: (req, res, id) ->
newStatus = req.body.status
unless newStatus in ['rejected', 'accepted', 'withdrawn']
@ -37,7 +48,7 @@ PatchHandler = class PatchHandler extends Handler
targetHandler = require('../' + handlers[targetInfo.collection])
targetModel = targetHandler.modelClass
query = { 'original': targetInfo.original }
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

View file

@ -52,4 +52,6 @@ PollHandler = class PollHandler extends Handler
return @sendDatabaseError(res, err) if err
@sendNoContent res
getNamesByIDs: (req, res) -> @getNamesByOriginals req, res, true
module.exports = new PollHandler()

View file

@ -159,18 +159,27 @@ UserSchema.statics.statsMapping =
'level.component': 'stats.levelComponentEdits'
'level.system': 'stats.levelSystemEdits'
'thang.type': 'stats.thangTypeEdits'
'Achievement': 'stats.achievementEdits'
'campaign': 'stats.campaignEdits'
'poll': 'stats.pollEdits'
translations:
article: 'stats.articleTranslationPatches'
level: 'stats.levelTranslationPatches'
'level.component': 'stats.levelComponentTranslationPatches'
'level.system': 'stats.levelSystemTranslationPatches'
'thang.type': 'stats.thangTypeTranslationPatches'
'Achievement': 'stats.achievementTranslationPatches'
'campaign': 'stats.campaignTranslationPatches'
'poll': 'stats.pollTranslationPatches'
misc:
article: 'stats.articleMiscPatches'
level: 'stats.levelMiscPatches'
'level.component': 'stats.levelComponentMiscPatches'
'level.system': 'stats.levelSystemMiscPatches'
'thang.type': 'stats.thangTypeMiscPatches'
'Achievement': 'stats.achievementMiscPatches'
'campaign': 'stats.campaignMiscPatches'
'poll': 'stats.pollMiscPatches'
UserSchema.statics.incrementStat = (id, statName, done, inc=1) ->
id = mongoose.Types.ObjectId id if _.isString id
@ -178,7 +187,7 @@ UserSchema.statics.incrementStat = (id, statName, done, inc=1) ->
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=1
user.incrementStat statName, done, inc
UserSchema.methods.incrementStat = (statName, done, inc=1) ->
@set statName, (@get(statName) or 0) + inc