[#8198] option to delete activity records
diff --git a/ForgeActivity/forgeactivity/main.py b/ForgeActivity/forgeactivity/main.py
index 6875d8b..0786db6 100644
--- a/ForgeActivity/forgeactivity/main.py
+++ b/ForgeActivity/forgeactivity/main.py
@@ -17,8 +17,10 @@
 
 import logging
 import calendar
+from datetime import timedelta
 from itertools import islice, ifilter
 
+from bson import ObjectId
 from ming.orm import session
 from pylons import tmpl_context as c, app_globals as g
 from pylons import request, response
@@ -27,6 +29,7 @@
 from paste.deploy.converters import asbool, asint
 from webob import exc
 from webhelpers import feedgenerator as FG
+from activitystream.storage.mingstorage import Activity
 
 from allura.app import Application
 from allura import version
@@ -208,6 +211,34 @@
             message=W.follow_toggle.success_message(follow),
             following=follow)
 
+    @require_post()
+    @expose('json:')
+    def delete_item(self, activity_id, **kwargs):
+        require_access(c.project.neighborhood, 'admin')
+        activity = Activity.query.get(_id=ObjectId(activity_id))
+        if not activity:
+            raise exc.HTTPGone
+        # find other copies of this activity on other user/projects timelines
+        # but only within a small time window, so we can do efficient searching
+        activity_ts = activity._id.generation_time
+        time_window = timedelta(hours=1)
+        all_copies = Activity.query.find({
+            '_id': {
+                '$gt': ObjectId.from_datetime(activity_ts - time_window),
+                '$lt': ObjectId.from_datetime(activity_ts + time_window),
+            },
+            'obj': activity.obj,
+            'target': activity.target,
+            'actor': activity.actor,
+            'verb': activity.verb,
+            'tags': activity.tags,
+        }).all()
+        log.info('Deleting %s copies of activity record: %s %s %s', len(all_copies),
+                 activity.actor.activity_url, activity.verb, activity.obj.activity_url)
+        for activity in all_copies:
+            activity.query.delete()
+        return {'success': True}
+
 
 class ForgeActivityRestController(BaseController, AppRestControllerMixin):
 
diff --git a/ForgeActivity/forgeactivity/nf/activity/css/activity.css b/ForgeActivity/forgeactivity/nf/activity/css/activity.css
index 55551de..f039ca9 100644
--- a/ForgeActivity/forgeactivity/nf/activity/css/activity.css
+++ b/ForgeActivity/forgeactivity/nf/activity/css/activity.css
@@ -49,6 +49,9 @@
   float: left;
   margin: 10px 10px 0 0;
 }
+.activity ul.timeline li input[name=delete] {
+  float: right;
+}
 .activity .page_list {
     margin-top: 5px;
 }
diff --git a/ForgeActivity/forgeactivity/nf/activity/js/activity.js b/ForgeActivity/forgeactivity/nf/activity/js/activity.js
index ad835d2..b0c83e7 100644
--- a/ForgeActivity/forgeactivity/nf/activity/js/activity.js
+++ b/ForgeActivity/forgeactivity/nf/activity/js/activity.js
@@ -282,9 +282,44 @@
         }
     }
 
+    function enableDeleteButtons() {
+        var confirmed = false;
+        $('.timeline').on('mouseenter', 'li[data-can-delete]', function() {
+            $(this).prepend('<input type=button value=Delete name=delete title="Permanently deletes this item from all users/projects activity records.<br>Only neighborhood admins can do this.">');
+            $('input[name=delete]', this).tooltipster({contentAsHTML: true});
+        });
+        $('.timeline').on('mouseleave', 'li[data-can-delete]', function() {
+            $('input[name=delete]', this).remove();
+        });
+        $('.timeline').on('click', 'li[data-can-delete] input[name=delete]', function() {
+            if (!confirmed) {
+                confirmed = confirm('Are you sure you want to delete this?  You cannot undo!');
+                if (!confirmed) {
+                    return;
+                }
+            }
+            $(this).prop('disabled', true);
+            $(this).val('Deleting...')
+            var $row = $(this).closest('[data-can-delete]');
+            $row.css('background', 'lightgray');
+            $.post('delete_item', {
+                activity_id: $row.attr('id'),
+                _session_id: $.cookie('_session_id')
+            }).done(function() {
+                $('input[name=delete]', $row).remove();
+                $row.css('text-decoration', 'line-through').removeAttr('data-can-delete');
+            }).fail(function() {
+                flash('Deleting failed.', 'error');
+                $row.css('background', 'orange');
+            });
+            return false;
+        });
+    }
+
     detectFeatures();
     enableScrollHistory();
     enableAdvancedPaging();
+    enableDeleteButtons();
 });
 
 function markTop() {
diff --git a/ForgeActivity/forgeactivity/templates/timeline.html b/ForgeActivity/forgeactivity/templates/timeline.html
index 499fe03..c2365d2 100644
--- a/ForgeActivity/forgeactivity/templates/timeline.html
+++ b/ForgeActivity/forgeactivity/templates/timeline.html
@@ -20,7 +20,7 @@
 {% import 'forgeactivity:templates/macros.html' as am with context %}
 
 {% for a in timeline %}
-<li id="{{a._id}}" data-page="{{page}}">
+<li id="{{a._id}}" data-page="{{page}}" {% if h.has_access(c.project.neighborhood, 'admin') %}data-can-delete{% endif %}>
   <time datetime="{{a.published|datetimeformat}}" title="{{a.published|datetimeformat}}">{{h.ago(a.published, show_date_after=None)}}</time>
   <h1>
       {{ am.icon(a.actor, 32, 'avatar') }}
diff --git a/ForgeActivity/forgeactivity/tests/functional/test_root.py b/ForgeActivity/forgeactivity/tests/functional/test_root.py
index 240ed7d..a2a812e 100644
--- a/ForgeActivity/forgeactivity/tests/functional/test_root.py
+++ b/ForgeActivity/forgeactivity/tests/functional/test_root.py
@@ -15,13 +15,16 @@
 #       specific language governing permissions and limitations
 #       under the License.
 
-from mock import patch
 from textwrap import dedent
-from tg import config
 
+from mock import patch
+from tg import config
+from bson import ObjectId
 import dateutil.parser
 from nose.tools import assert_equal
 from pylons import app_globals as g
+from activitystream.storage.mingstorage import Activity
+from ming.odm import ThreadLocalODMSession
 
 from allura import model as M
 from alluratest.controller import TestController
@@ -411,3 +414,67 @@
         assert_equal(
             activity.find('{http://www.w3.org/2005/Atom}link').get('href'),
             'http://localhost/p/test/tickets/34/?limit=25#ed7c')
+
+    @td.with_tool('u/test-user-1', 'activity')
+    @td.with_user_project('test-user-1')
+    def test_delete_item_denied(self):
+        self.app.post('/u/test-user-1/activity/delete_item',
+                      {'activity_id': str(ObjectId())},
+                      status=403)
+
+    @td.with_tool('u/test-user-1', 'activity')
+    @td.with_user_project('test-user-1')
+    def test_delete_item_gone(self):
+        self.app.post('/u/test-user-1/activity/delete_item',
+                      {'activity_id': str(ObjectId())},
+                      extra_environ={'username': 'root'},  # nbhd admin
+                      status=410)
+
+    @td.with_tool('u/test-user-1', 'activity')
+    @td.with_user_project('test-user-1')
+    def test_delete_item_success(self):
+        activity_data = {
+            "obj": {
+                "activity_extras": {
+                    "summary": "Sensitive private info, oops"
+                },
+                "activity_url": "/p/test/tickets/34/?limit=25#ed7c",
+                "activity_name": "a comment"
+            },
+            "target": {
+                "activity_extras": {
+                    "allura_id": "Ticket:529f57a6033c5e5985db2efa",
+                    "summary": "Make activitystream timeline look better"
+                },
+                "activity_url": "/p/test/tickets/34/",
+                "activity_name": "ticket #34"
+            },
+            "actor": {
+                "activity_extras": {
+                    "icon_url": "/u/test-admin/user_icon",
+                    "allura_id": "User:521f96cb033c5e2587adbdff"
+                },
+                "activity_url": "/u/test-admin/",
+                "activity_name": "Administrator 1",
+                "node_id": "User:521f96cb033c5e2587adbdff"
+            },
+            "verb": "posted",
+            "published": dateutil.parser.parse("2013-12-04T21:48:19.817"),
+            "score": 1386193699,
+            "node_id": "Project:527a6584033c5e62126f5a60",
+            "owner_id": "Project:527a6584033c5e62126f5a60"
+        }
+        activity = Activity(**activity_data)
+        activity2 = Activity(**dict(activity_data, node_id='Project:123', owner_id='User:456'))
+        activity3 = Activity(**dict(activity_data, node_id='User:abc', owner_id='User:abc'))
+        ThreadLocalODMSession.flush_all()
+        activity_id = str(activity._id)
+        assert_equal(Activity.query.find({'obj.activity_extras.summary': 'Sensitive private info, oops'}).count(), 3)
+
+        self.app.post('/u/test-user-1/activity/delete_item',
+                      {'activity_id': activity_id},
+                      extra_environ={'username': 'root'},  # nbhd admin
+                      status=200)
+        ThreadLocalODMSession.flush_all()
+
+        assert_equal(Activity.query.find({'obj.activity_extras.summary': 'Sensitive private info, oops'}).count(), 0)
diff --git a/requirements.txt b/requirements.txt
index eba6442..8c8db42 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -21,7 +21,7 @@
 iso8601==0.1.4
 Jinja2==2.9.6
 Markdown==2.2.1
-Ming==0.5.5
+Ming==0.5.6
 oauth2==1.5.170
 # tg2 dep PasteDeploy must specified before TurboGears2, to avoid a version/allow-hosts problem
 Paste==1.7.5.1