[#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