diff --git a/app/core/Router.coffee b/app/core/Router.coffee index 29ad86fb2..5dee4e159 100644 --- a/app/core/Router.coffee +++ b/app/core/Router.coffee @@ -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') diff --git a/app/models/Poll.coffee b/app/models/Poll.coffee index c47bb1962..4f72455dd 100644 --- a/app/models/Poll.coffee +++ b/app/models/Poll.coffee @@ -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 diff --git a/app/styles/editor/campaign/campaign-editor-view.sass b/app/styles/editor/campaign/campaign-editor-view.sass index 8ce962a8d..87f60f7e3 100644 --- a/app/styles/editor/campaign/campaign-editor-view.sass +++ b/app/styles/editor/campaign/campaign-editor-view.sass @@ -21,3 +21,9 @@ bottom: 0 right: 0 width: 75% + + .patches-view + position: absolute + left: 20px + top: 20px + z-index: 30 diff --git a/app/styles/editor/patches.sass b/app/styles/editor/patches.sass index f4130ec5a..d00a22c9c 100644 --- a/app/styles/editor/patches.sass +++ b/app/styles/editor/patches.sass @@ -2,5 +2,5 @@ .status-buttons margin-bottom: 10px - .patch-icon + .patch-row cursor: pointer diff --git a/app/templates/admin.jade b/app/templates/admin.jade index ebf3cbc14..acc1dd621 100644 --- a/app/templates/admin.jade +++ b/app/templates/admin.jade @@ -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 diff --git a/app/templates/admin/pending-patches-view.jade b/app/templates/admin/pending-patches-view.jade new file mode 100644 index 000000000..2cc7de051 --- /dev/null +++ b/app/templates/admin/pending-patches-view.jade @@ -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} + \ No newline at end of file diff --git a/app/templates/editor/campaign/campaign-editor-view.jade b/app/templates/editor/campaign/campaign-editor-view.jade index b503647b2..54176c7a7 100644 --- a/app/templates/editor/campaign/campaign-editor-view.jade +++ b/app/templates/editor/campaign/campaign-editor-view.jade @@ -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 diff --git a/app/templates/editor/patches.jade b/app/templates/editor/patches.jade index 43b24bc30..487067d54 100644 --- a/app/templates/editor/patches.jade +++ b/app/templates/editor/patches.jade @@ -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 diff --git a/app/views/admin/PendingPatchesView.coffee b/app/views/admin/PendingPatchesView.coffee new file mode 100644 index 000000000..99a4b2b50 --- /dev/null +++ b/app/views/admin/PendingPatchesView.coffee @@ -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 ||= '' + 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() diff --git a/app/views/editor/PatchModal.coffee b/app/views/editor/PatchModal.coffee index 99a44661c..9b8b411bd 100644 --- a/app/views/editor/PatchModal.coffee +++ b/app/views/editor/PatchModal.coffee @@ -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 diff --git a/app/views/editor/PatchesView.coffee b/app/views/editor/PatchesView.coffee index 7e284ae28..feb291197 100644 --- a/app/views/editor/PatchesView.coffee +++ b/app/views/editor/PatchesView.coffee @@ -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' diff --git a/app/views/editor/achievement/AchievementEditView.coffee b/app/views/editor/achievement/AchievementEditView.coffee index f798ba1a6..3fca64907 100644 --- a/app/views/editor/achievement/AchievementEditView.coffee +++ b/app/views/editor/achievement/AchievementEditView.coffee @@ -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) diff --git a/app/views/editor/article/ArticleEditView.coffee b/app/views/editor/article/ArticleEditView.coffee index 4cfaf04e8..5e2a212d1 100644 --- a/app/views/editor/article/ArticleEditView.coffee +++ b/app/views/editor/article/ArticleEditView.coffee @@ -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) diff --git a/app/views/editor/campaign/CampaignEditorView.coffee b/app/views/editor/campaign/CampaignEditorView.coffee index 072e4c7b1..4fa466830 100644 --- a/app/views/editor/campaign/CampaignEditorView.coffee +++ b/app/views/editor/campaign/CampaignEditorView.coffee @@ -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) => diff --git a/app/views/editor/poll/PollEditView.coffee b/app/views/editor/poll/PollEditView.coffee index e0a92ad17..2d4f27300 100644 --- a/app/views/editor/poll/PollEditView.coffee +++ b/app/views/editor/poll/PollEditView.coffee @@ -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) diff --git a/app/views/ladder/MyMatchesTabView.coffee b/app/views/ladder/MyMatchesTabView.coffee index 327335b31..b03b87726 100644 --- a/app/views/ladder/MyMatchesTabView.coffee +++ b/app/views/ladder/MyMatchesTabView.coffee @@ -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) => diff --git a/scripts/mongodb/queries/patches.js b/scripts/mongodb/queries/patches.js index 771aadd21..2582f5f10 100644 --- a/scripts/mongodb/queries/patches.js +++ b/scripts/mongodb/queries/patches.js @@ -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); @@ -22,6 +28,12 @@ for(var i in patches) { print(target.name, 'made by unknown person...'); continue; } - - print(target.name, 'made by', creator.name); - } \ No newline at end of file + + 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); +} diff --git a/server/achievements/achievement_handler.coffee b/server/achievements/achievement_handler.coffee index 92addeecd..6377f73dc 100644 --- a/server/achievements/achievement_handler.coffee +++ b/server/achievements/achievement_handler.coffee @@ -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() diff --git a/server/campaigns/campaign_handler.coffee b/server/campaigns/campaign_handler.coffee index 7ca1bd372..6b40def2e 100644 --- a/server/campaigns/campaign_handler.coffee +++ b/server/campaigns/campaign_handler.coffee @@ -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() diff --git a/server/commons/Handler.coffee b/server/commons/Handler.coffee index 5145915dc..44f459518 100644 --- a/server/commons/Handler.coffee +++ b/server/commons/Handler.coffee @@ -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) diff --git a/server/patches/patch_handler.coffee b/server/patches/patch_handler.coffee index 9593d8c83..d2235e2a6 100644 --- a/server/patches/patch_handler.coffee +++ b/server/patches/patch_handler.coffee @@ -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 diff --git a/server/polls/poll_handler.coffee b/server/polls/poll_handler.coffee index 2df6550fb..0245e2e92 100644 --- a/server/polls/poll_handler.coffee +++ b/server/polls/poll_handler.coffee @@ -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() diff --git a/server/users/User.coffee b/server/users/User.coffee index e572a837a..c59096aa8 100644 --- a/server/users/User.coffee +++ b/server/users/User.coffee @@ -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