[#8280] add mechanism to block user and spam all their posts
diff --git a/Allura/allura/controllers/discuss.py b/Allura/allura/controllers/discuss.py
index 7209242..52a1b3c 100644
--- a/Allura/allura/controllers/discuss.py
+++ b/Allura/allura/controllers/discuss.py
@@ -337,7 +337,11 @@
                     {'artifact_id': self.post._id, 'version': int(version)}).first()
                 if not ss:
                     raise exc.HTTPNotFound
-                post = Object(
+
+                class VersionedSnapshotTempObject(Object):
+                    pass
+
+                post = VersionedSnapshotTempObject(
                     ss.data,
                     acl=self.post.acl,
                     author=self.post.author,
@@ -518,29 +522,59 @@
     @expose()
     @require_post()
     def save_moderation(self, post=[], delete=None, spam=None, approve=None, **kw):
+        count = 0
         for p in post:
-            if 'checked' in p:
-                posted = self.PostModel.query.get(
-                    _id=p['_id'],
-                    # make sure nobody hacks the HTML form to moderate other
-                    # posts
-                    discussion_id=self.discussion._id,
-                )
-                if posted:
-                    if delete:
-                        posted.delete()
-                        # If we just deleted the last post in the
-                        # thread, delete the thread.
-                        if posted.thread and posted.thread.num_replies == 0:
-                            posted.thread.delete()
-                    elif spam and posted.status != 'spam':
-                        posted.spam()
-                    elif approve and posted.status != 'ok':
-                        posted.approve()
-                        g.spam_checker.submit_ham(posted.text, artifact=posted, user=posted.author())
-                        posted.thread.post_to_feed(posted)
+            posted = None
+            if isinstance(p, dict):
+                # regular form submit
+                if 'checked' in p:
+                    posted = self.PostModel.query.get(
+                        _id=p['_id'],
+                        # make sure nobody hacks the HTML form to moderate other
+                        # posts
+                        discussion_id=self.discussion._id,
+                    )
+            elif isinstance(p, self.PostModel):
+                # called from save_moderation_bulk_user with models already
+                posted = p
+            else:
+                raise TypeError('post list should be form fields, or Post models')
+
+            if posted:
+                if delete:
+                    posted.delete()
+                    # If we just deleted the last post in the
+                    # thread, delete the thread.
+                    if posted.thread and posted.thread.num_replies == 0:
+                        count += 1
+                        posted.thread.delete()
+                elif spam and posted.status != 'spam':
+                    count += 1
+                    posted.spam()
+                elif approve and posted.status != 'ok':
+                    count += 1
+                    posted.approve()
+                    g.spam_checker.submit_ham(posted.text, artifact=posted, user=posted.author())
+                    posted.thread.post_to_feed(posted)
+        flash(u'{} {}'.format(h.text.plural(count, 'post', 'posts'),
+                              'deleted' if delete else 'marked as spam' if spam else 'approved'))
         redirect(request.referer or '/')
 
+    @expose()
+    @require_post()
+    def save_moderation_bulk_user(self, username, **kw):
+        # this is used by post.js as a quick way to deal with all a user's posts
+        user = User.by_username(username)
+        posts = self.PostModel.query.find({
+            'author_id': user._id,
+            'deleted': False,
+            # this is what the main moderation forms does (e.g. single discussion within a forum app)
+            # 'discussion_id': self.discussion._id
+            # but instead want to do all discussions within this app
+            'app_config_id': c.app.config._id
+        })
+        return self.save_moderation(posts, **kw)
+
 
 class PostRestController(PostController):
 
diff --git a/Allura/allura/lib/widgets/resources/js/post.js b/Allura/allura/lib/widgets/resources/js/post.js
index 0a47a48..a4811dd 100644
--- a/Allura/allura/lib/widgets/resources/js/post.js
+++ b/Allura/allura/lib/widgets/resources/js/post.js
@@ -55,6 +55,35 @@
             });
         });
 
+        $('.spam-all-block', post).click(function(e) {
+            e.preventDefault();
+            var $this = $(this);
+            var cval = $.cookie('_session_id');
+            $.ajax({
+                type: 'POST',
+                url: $this.attr('data-admin-url') + '/block_user',
+                data: {
+                    username: $this.attr('data-user'),
+                    perm: 'post',
+                    '_session_id': cval
+                },
+                success: function (data, textStatus, jqxhr) {
+                    if (data.error) {
+                        flash(data.error, 'error');
+                    } else if (data.username) {
+                        flash('User blocked', 'success');
+                        // full page form submit
+                        $('<form method="POST" action="' + $this.data('discussion-url')+'moderate/save_moderation_bulk_user?username=' + $this.attr('data-user') + '&spam=1">' +
+                            '<input name="_session_id" type="hidden" value="'+cval+'"></form>')
+                            .appendTo('body')
+                            .submit();
+                    } else {
+                        flash('Error.  Make sure you are logged in still.', 'error');
+                    }
+                }
+            });
+        });
+
         function spam_block_display($post, display_type) {
             var spam_block = $post.find('.info.grid-15.spam-present');
             var row = $post.find('.comment-row').eq(0);
diff --git a/Allura/allura/templates/widgets/post_widget.html b/Allura/allura/templates/widgets/post_widget.html
index bd03f92..0d1e6ee 100644
--- a/Allura/allura/templates/widgets/post_widget.html
+++ b/Allura/allura/templates/widgets/post_widget.html
@@ -36,9 +36,14 @@
         <a href="" class="moderate_post little_link"><span>Undo</span></a>
         {{lib.csrf_token()}}
       </form>
+      {% if value.discussion and value.app_config %}{# avoid errors, when viewing old versions of comments (obscure, but has test coverage) #}
       <br>
-      <span class="spam-text">You can see all pending comments posted by this user&nbsp;</span>
-      <a href="{{value.thread.discussion.url()}}moderate?username={{value.author().username}}&status=pending">here</a>
+      View and moderate
+      <a href="{{value.thread.discussion.url()}}moderate?username={{value.author().username}}&status=-">all "{{ value.discussion.name }}" comments posted by this user</a>
+      <br><br>
+      <a href="#" class="spam-all-block" data-user="{{ value.author().username }}" data-admin-url="{{ c.app.admin_url }}" data-discussion-url="{{value.thread.discussion.url()}}">
+          Mark all as spam, and block user from posting to "{{ value.app_config.options.mount_label }}"</a>
+      {% endif %}
     </div>
     {% endif %}
     <div class="comment-row">
diff --git a/Allura/allura/tests/unit/controllers/test_discussion_moderation_controller.py b/Allura/allura/tests/unit/controllers/test_discussion_moderation_controller.py
index 58c98ef..e6d0353 100644
--- a/Allura/allura/tests/unit/controllers/test_discussion_moderation_controller.py
+++ b/Allura/allura/tests/unit/controllers/test_discussion_moderation_controller.py
@@ -63,9 +63,10 @@
         assert_equal(self.get_post(), None)
 
     def moderate_post(self, **kwargs):
-        self.controller.save_moderation(
-            post=[dict(checked=True, _id=self.get_post()._id)],
-            **kwargs)
+        with patch('allura.controllers.discuss.flash'):
+            self.controller.save_moderation(
+                post=[dict(checked=True, _id=self.get_post()._id)],
+                **kwargs)
         ThreadLocalORMSession.flush_all()
 
     def get_post(self):
diff --git a/ForgeDiscussion/forgediscussion/tests/functional/test_forum.py b/ForgeDiscussion/forgediscussion/tests/functional/test_forum.py
index 44cc42f..103d79f 100644
--- a/ForgeDiscussion/forgediscussion/tests/functional/test_forum.py
+++ b/ForgeDiscussion/forgediscussion/tests/functional/test_forum.py
@@ -446,6 +446,30 @@
         input_field = r.html.fieldset.find('input', {'value': username})
         assert input_field is None
 
+    def test_save_moderation_bulk_user(self):
+        # create posts
+        for i in range(5):
+            r = self.app.get('/discussion/create_topic/')
+            f = r.html.find(
+                'form', {'action': '/p/test/discussion/save_new_topic'})
+            params = dict()
+            inputs = f.findAll('input')
+            for field in inputs:
+                if field.has_key('name'):  # nopep8 - beautifulsoup3 actually uses has_key
+                    params[field['name']] = field.get('value') or ''
+            params[f.find('textarea')['name']] = 'Post text'
+            params[f.find('select')['name']] = 'testforum'
+            params[f.find('input', {'style': 'width: 90%'})['name']] = "this is my post"
+            r = self.app.post('/discussion/save_new_topic', params=params)
+
+        assert_equal(5, FM.ForumPost.query.find({'status': 'ok'}).count())
+
+        r = self.app.post('/discussion/testforum/moderate/save_moderation_bulk_user', params={
+            'username': 'test-admin',
+            'spam': '1'})
+        assert_in(u'5 posts marked as spam', self.webflash(r))
+        assert_equal(5, FM.ForumPost.query.find({'status': 'spam'}).count())
+
     def test_posting(self):
         r = self.app.get('/discussion/create_topic/')
         f = r.html.find('form', {'action': '/p/test/discussion/save_new_topic'})