diff --git a/apps/amo/log.py b/apps/amo/log.py
index 0343658ffa4..34de828ae7c 100644
--- a/apps/amo/log.py
+++ b/apps/amo/log.py
@@ -10,30 +10,30 @@
class CREATE_ADDON:
id = 1
- format = _(u'{user.name} created addon {addon.name}')
+ format = _(u'{addon} was created.')
keep = True
class EDIT_PROPERTIES:
""" Expects: addon """
id = 2
- format = _(u'{user.name} edited addon {addon.name} properties')
+ format = _(u'{user.name} edited addon {addon} properties')
class EDIT_DESCRIPTIONS:
id = 3
- format = _(u'{user.name} edited addon {addon.name} description')
+ format = _(u'{user.name} edited addon {addon} description')
class EDIT_CATEGORIES:
id = 4
- format = _(u'{user.name} edited categories for {addon.name}')
+ format = _(u'{user.name} edited categories for {addon}')
class ADD_USER_WITH_ROLE:
id = 5
format = _(u'{user.name} added {0.name} to '
- 'addon {addon.name} with role {1}')
+ 'addon {addon} with role {1}')
keep = True
@@ -46,18 +46,18 @@ class REMOVE_USER_WITH_ROLE:
class EDIT_CONTRIBUTIONS:
id = 7
- format = _(u'{user.name} edited contributions for {addon.name}')
+ format = _(u'{user.name} edited contributions for {addon}')
class SET_INACTIVE:
id = 8
- format = _(u'{user.name} set addon {addon.name} inactive')
+ format = _(u'{addon} set inactive')
keep = True
class UNSET_INACTIVE:
id = 9
- format = _(u'{user.name} activated addon {addon.name}')
+ format = _(u'{user.name} activated addon {addon}')
keep = True
@@ -97,7 +97,7 @@ class DELETE_PREVIEW:
class ADD_VERSION:
id = 16
- format = _(u'{user.name} added version {0.version} to {addon}')
+ format = _(u'{version} added to {addon}.')
keep = True
@@ -125,8 +125,7 @@ class DELETE_FILE_FROM_VERSION:
should be strings and not the object.
"""
id = 20
- format = _(u'{user.name} deleted file {0} '
- 'from {addon} version {1}')
+ format = _(u'File {0} deleted from {version} of {addon}')
class APPROVE_VERSION:
@@ -170,17 +169,17 @@ class REMOVE_TAG:
class ADD_TO_COLLECTION:
id = 27
- format = _(u'{user.name} added addon {addon} to a collection {0.name}')
+ format = _(u'{addon} added to {collection}.')
class REMOVE_FROM_COLLECTION:
id = 28
- forma = _(u'{user.name} removed addon {addon} from a collection {0.name}')
+ forma = _(u'{addon} removed from {collection}')
class ADD_REVIEW:
id = 29
- format = _(u'{user.name} wrote a review about {addon}')
+ format = _(u'{review} for {addon} written.')
class ADD_RECOMMENDED_CATEGORY:
diff --git a/apps/devhub/models.py b/apps/devhub/models.py
index d2e04ac0f57..ebebf242f1d 100644
--- a/apps/devhub/models.py
+++ b/apps/devhub/models.py
@@ -1,16 +1,20 @@
+from copy import copy
from datetime import datetime
import json
from django.db import models
import commonware.log
+from tower import ugettext_lazy as _
import amo
import amo.models
from addons.models import Addon
-from users.models import UserProfile
+from bandwagon.models import Collection
+from reviews.models import Review
from translations.fields import TranslatedField
-
+from users.models import UserProfile
+from versions.models import Version
log = commonware.log.getLogger('devhub')
@@ -79,13 +83,20 @@ class Meta:
class ActivityLogManager(amo.models.ManagerBase):
- def for_addon(self, addon, limit=20, offset=0):
- vals = (AddonLog.objects.filter(addon=addon)[offset:limit]
+ def for_addons(self, addons):
+ if isinstance(addons, Addon):
+ addons = (addons,)
+
+ vals = (AddonLog.objects.filter(addon__in=addons)
.values_list('activity_log', flat=True))
- return self.filter(pk__in=list(vals))
- def for_user(self, user, limit=20, offset=0):
- vals = (UserLog.objects.filter(user=user)[offset:limit]
+ if vals:
+ return self.filter(pk__in=list(vals))
+ else:
+ return self.none()
+
+ def for_user(self, user):
+ vals = (UserLog.objects.filter(user=user)
.values_list('activity_log', flat=True))
return self.filter(pk__in=list(vals))
@@ -169,22 +180,48 @@ def log(cls, request, action, arguments=None):
# TODO(davedash): Support other types.
def to_string(self, type='default'):
log_type = amo.LOG_BY_ID[self.action]
- arguments = self.arguments
+
+ # We need to copy arguments so we can remove elements from it
+ # while we loop over self.arguments.
+ arguments = copy(self.arguments)
addon = None
- for arg in arguments:
+ review = None
+ version = None
+ collection = None
+ for arg in self.arguments:
if isinstance(arg, Addon) and not addon:
- addon = arg
- break
+ addon = u'%s' % (arg.get_url_path(), arg.name)
+ arguments.remove(arg)
+ if isinstance(arg, Review) and not review:
+ review = u'%s' % (arg.get_url_path(),
+ _('Review'))
+ arguments.remove(arg)
+ if isinstance(arg, Version) and not version:
+ text = _('Version %s') % arg.version
+ version = u'%s' % (arg.get_url_path(), text)
+ arguments.remove(arg)
+ if isinstance(arg, Collection) and not collection:
+ collection = u'%s' % (arg.get_url_path(),
+ arg.name)
+ arguments.remove(arg)
- return log_type.format.format(*arguments, user=self.user, addon=addon)
+ try:
+ data = dict(user=self.user, addon=addon, review=review,
+ version=version, collection=collection)
+ return log_type.format.format(*arguments, **data)
+ except (AttributeError, KeyError, IndexError):
+ log.warning('%d contains garbage data' % self.id)
+ return 'Something magical happened.'
def __unicode__(self):
return self.to_string()
class Meta:
db_table = 'log_activity'
+ ordering = ('-created',)
+# TODO(davedash): Remove after we finish the import.
class LegacyAddonLog(models.Model):
TYPES = [(value, key) for key, value in amo.LOG.items()]
diff --git a/apps/devhub/templates/devhub/addons/activity.html b/apps/devhub/templates/devhub/addons/activity.html
index aaa87d6874b..0f85331673c 100644
--- a/apps/devhub/templates/devhub/addons/activity.html
+++ b/apps/devhub/templates/devhub/addons/activity.html
@@ -5,7 +5,7 @@
{% endblock %}
{% block rss_feed %}
-{# TODO: Add ```` to 'Recent Activity' RSS feed. #}
+{# TODO(davedash): Add ```` to 'Recent Activity' RSS feed. #}
{% endblock %}
{% block content %}
@@ -17,14 +17,25 @@
{{ _('Recent Activity for My Add-ons') }}
+ {% if pager.object_list %}
+ {% for item in pager.object_list %}
+
+
{{ item|safe }}
+
+ {% trans user=item.user|user_link, ago=item.created|timesince %}
+ {{ ago }} by {{ user }}
+ {% endtrans %}
+
-{# TODO(cvan): Add item for each activity update. #}
-
-
+
+ {% endfor %}
+ {% else %}
+
{{ _('No results found.') }}
+ {% endif %}
+ {% if pager.has_other_pages() %}
+
+ {% endif %}
@@ -37,17 +48,9 @@ {{ _('Refine Activity') }}
{{ _('Add-on') }}
@@ -55,18 +58,9 @@ {{ _('Add-on') }}
{{ _('Activity') }}
diff --git a/apps/devhub/tests/locale/zz/LC_MESSAGES/django.mo b/apps/devhub/tests/locale/zz/LC_MESSAGES/django.mo
index e91f8576e2c..a2cd2ee7c88 100644
Binary files a/apps/devhub/tests/locale/zz/LC_MESSAGES/django.mo and b/apps/devhub/tests/locale/zz/LC_MESSAGES/django.mo differ
diff --git a/apps/devhub/tests/locale/zz/LC_MESSAGES/django.po b/apps/devhub/tests/locale/zz/LC_MESSAGES/django.po
index f9fc2f1c11d..3a4d21b1fce 100644
--- a/apps/devhub/tests/locale/zz/LC_MESSAGES/django.po
+++ b/apps/devhub/tests/locale/zz/LC_MESSAGES/django.po
@@ -17,5 +17,5 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
-msgid "{user.name} added {0.name} to addon {addon.name} with role {1}"
-msgstr "(ZZ) {user.name} added {0.name} to addon {addon.name} with role {1}"
+msgid "{user.name} added {0.name} to addon {addon} with role {1}"
+msgstr "(ZZ) {user.name} added {0.name} to addon {addon} with role {1}"
diff --git a/apps/devhub/tests/test_models.py b/apps/devhub/tests/test_models.py
index 6ddc694ea0d..8a2135cd9e0 100644
--- a/apps/devhub/tests/test_models.py
+++ b/apps/devhub/tests/test_models.py
@@ -26,11 +26,11 @@ def test_basic(self):
request = self.request
a = Addon.objects.get()
ActivityLog.log(request, amo.LOG['CREATE_ADDON'], a)
- entries = ActivityLog.objects.for_addon(a)
+ entries = ActivityLog.objects.for_addons(a)
eq_(len(entries), 1)
eq_(entries[0].arguments[0], a)
- eq_(unicode(entries[0]),
- 'Joe CamelCase created addon Delicious Bookmarks')
+ for x in ('Delicious Bookmarks', 'was created.'):
+ assert x in unicode(entries[0])
def test_json_failboat(self):
request = self.request
diff --git a/apps/devhub/tests/test_views.py b/apps/devhub/tests/test_views.py
index 7a9993272d4..0aed0cd634e 100644
--- a/apps/devhub/tests/test_views.py
+++ b/apps/devhub/tests/test_views.py
@@ -1,13 +1,14 @@
-from decimal import Decimal
import re
import socket
+from decimal import Decimal
+from urllib import urlencode
from django import forms
from django.conf import settings
from django.utils import translation
import mock
-from nose.tools import eq_, assert_not_equal, set_trace
+from nose.tools import eq_, assert_not_equal
from pyquery import PyQuery as pq
import test_utils
@@ -16,9 +17,11 @@
from amo.urlresolvers import reverse
from addons.models import Addon, AddonUser, Charity
from applications.models import AppVersion
+from bandwagon.models import Collection
from devhub.forms import ContribForm
from devhub.models import ActivityLog
from files.models import File, Platform
+from reviews.models import Review
from users.models import UserProfile
from versions.models import ApplicationsVersions, License, Version
@@ -53,6 +56,124 @@ def clone_addon(self, num_copies, addon_id=57132):
self.num_addon_clones += 1
+class TestActivity(HubTest):
+ """Test the activity feed."""
+
+ def setUp(self):
+ """Start with one user, two add-ons."""
+ super(TestActivity, self).setUp()
+ self.clone_addon(2)
+ self.request = mock.Mock()
+ self.request.amo_user = self.user_profile
+ self.addon, self.addon2 = list(self.user_profile.addons.all())
+
+ def log_creates(self, num, addon=None):
+ if not addon:
+ addon = self.addon
+ for i in xrange(num):
+ ActivityLog.log(self.request, amo.LOG.CREATE_ADDON, addon)
+
+ def log_updates(self, num):
+ version = Version.objects.create(version='1', addon=self.addon)
+ for i in xrange(num):
+ ActivityLog.log(self.request, amo.LOG.ADD_VERSION,
+ (self.addon, version))
+
+ def log_status(self, num):
+ for i in xrange(num):
+ ActivityLog.log(self.request, amo.LOG.SET_INACTIVE, (self.addon))
+
+ def log_collection(self, num):
+ for i in xrange(num):
+ c = Collection(name='foo %d' % i)
+ ActivityLog.log(self.request, amo.LOG.ADD_TO_COLLECTION,
+ (self.addon, c))
+
+ def log_review(self, num):
+ r = Review(addon=self.addon)
+ for i in xrange(num):
+ ActivityLog.log(self.request, amo.LOG.ADD_REVIEW, (self.addon, r))
+
+ def get_pq(self, **kwargs):
+ url = reverse('devhub.addons.activity')
+ if kwargs:
+ url += '?' + urlencode(kwargs)
+ r = self.client.get(url, follow=True)
+ return pq(r.content)
+
+ def test_items(self):
+ self.log_creates(10)
+ doc = self.get_pq()
+ eq_(len(doc('.item')), 10)
+
+ def test_filter_updates(self):
+ self.log_creates(10)
+ self.log_updates(10)
+ doc = self.get_pq()
+ eq_(len(doc('.item')), 20)
+ doc = self.get_pq(action='updates')
+ eq_(len(doc('.item')), 10)
+
+ def test_filter_status(self):
+ self.log_creates(10)
+ self.log_status(5)
+ doc = self.get_pq()
+ eq_(len(doc('.item')), 15)
+ doc = self.get_pq(action='status')
+ eq_(len(doc('.item')), 5)
+
+ def test_filter_collections(self):
+ self.log_creates(10)
+ self.log_collection(3)
+ doc = self.get_pq()
+ eq_(len(doc('.item')), 13)
+ doc = self.get_pq(action='collections')
+ eq_(len(doc('.item')), 3)
+
+ def test_filter_reviews(self):
+ self.log_creates(10)
+ self.log_review(10)
+ doc = self.get_pq()
+ eq_(len(doc('.item')), 20)
+ doc = self.get_pq(action='reviews')
+ eq_(len(doc('.item')), 10)
+
+ def test_pagination(self):
+ self.log_review(21)
+ doc = self.get_pq()
+
+ # 20 items on page 1.
+ eq_(len(doc('.item')), 20)
+
+ # 1 item on page 2
+ doc = self.get_pq(page=2)
+ eq_(len(doc('.item')), 1)
+
+ # we have a pagination thingy
+ eq_(len(doc('.pagination')), 1)
+ assert doc('.listing-footer')
+
+ def test_no_pagination(self):
+ doc = self.get_pq()
+ assert not doc('.listing-footer')
+
+ def test_filter_addon(self):
+ self.log_creates(10)
+ self.log_creates(13, self.addon2)
+
+ # We show everything without filters
+ doc = self.get_pq()
+ eq_(len(doc('.item')), 20)
+
+ # We just show addon1
+ doc = self.get_pq(addon=self.addon.id)
+ eq_(len(doc('.item')), 10)
+
+ # we just show addon2
+ doc = self.get_pq(addon=self.addon2.id)
+ eq_(len(doc('.item')), 13)
+
+
class TestNav(HubTest):
def test_navbar(self):
@@ -872,7 +993,7 @@ def test_log(self):
d = dict(the_reason='because', the_future='i can')
o = ActivityLog.objects
eq_(o.count(), 0)
- r = self.client.post(self.url, d)
+ self.client.post(self.url, d)
eq_(o.filter(action=amo.LOG.EDIT_PROPERTIES.id).count(), 1)
def test_with_contributions_fields_required(self):
@@ -1021,10 +1142,11 @@ def test_delete_file(self):
eq_(ActivityLog.objects.count(), 1)
log = ActivityLog.objects.all()[0]
- eq_(log.to_string(), u'55021 \u0627\u0644\u062a\u0637\u0628 deleted '
- 'file delicious_bookmarks-2.1.072-fx.xpi from '
- '3615: Delicious Bookmarks '
- 'version 2.1.072')
+ eq_(log.to_string(), u'File delicious_bookmarks-2.1.072-fx.xpi '
+ 'deleted from Version 2.1.072 of Delicious '
+ 'Bookmarks')
eq_(r.status_code, 302)
eq_(self.version.files.count(), 0)
diff --git a/apps/devhub/views.py b/apps/devhub/views.py
index 7da8baf0783..10968275275 100644
--- a/apps/devhub/views.py
+++ b/apps/devhub/views.py
@@ -15,12 +15,14 @@
from tower import ugettext as _
import amo
-from amo import messages
import amo.utils
+from amo import messages
+from amo.helpers import urlparams
+from amo.utils import MenuItem
from amo.decorators import json_view, login_required, post_required
from access import acl
-import addons.forms
-from addons.models import Addon, AddonUser, AddonLog
+from addons import forms as addon_forms
+from addons.models import Addon, AddonUser
from addons.views import BaseFilter
from devhub.models import ActivityLog
from files.models import FileUpload
@@ -111,9 +113,79 @@ def ajax_compat_update(request, addon_id, addon, version_id):
compat_form=compat_form))
+def _get_addons(request, addons, addon_id):
+ """Create a list of ``MenuItem``s for the activity feed."""
+ items = []
+ url = request.get_full_path()
+
+ a = MenuItem()
+ a.selected = (not addon_id)
+ (a.text, a.url) = (_('All My Add-ons'), urlparams(url, page=None,
+ addon=None))
+ items.append(a)
+
+ for addon in addons:
+ item = MenuItem()
+ item.selected = (addon.id == addon_id)
+ (item.text, item.url) = (addon.name, urlparams(url, page=None,
+ addon=addon.id))
+ items.append(item)
+
+ return items
+
+
+def _get_activities(request, action):
+ url = request.get_full_path()
+ choices = (None, 'updates', 'status', 'collections', 'reviews')
+ text = {None: _('All Activity'),
+ 'updates': _('Add-on Updates'),
+ 'status': _('Add-on Status'),
+ 'collections': _('User Collections'),
+ 'reviews': _('User Reviews'),
+ }
+
+ items = []
+ for c in choices:
+ i = MenuItem()
+ i.text = text[c]
+ i.url, i.selected = urlparams(url, page=None, action=c), (action == c)
+ items.append(i)
+
+ return items
+
+
+def _get_filter(action):
+ filters = dict(updates=(amo.LOG.ADD_VERSION, amo.LOG.ADD_FILE_TO_VERSION),
+ status=(amo.LOG.SET_INACTIVE, amo.LOG.UNSET_INACTIVE,
+ amo.LOG.CHANGE_STATUS, amo.LOG.APPROVE_VERSION,),
+ collections=(amo.LOG.ADD_TO_COLLECTION,
+ amo.LOG.REMOVE_FROM_COLLECTION,),
+ reviews=(amo.LOG.ADD_REVIEW,))
+
+ return filters.get(action)
+
+
@login_required
def activity(request):
- return jingo.render(request, 'devhub/addons/activity.html')
+ addons_all = request.amo_user.addons.all()
+
+ try:
+ addon_id = int(request.GET.get('addon'))
+ addons = addons_all.filter(pk=addon_id)
+ except (ValueError, TypeError):
+ addon_id = None
+ addons = addons_all
+
+ action = request.GET.get('action')
+ activities = _get_activities(request, action)
+ filter = _get_filter(action)
+ addon_items = _get_addons(request, addons_all, addon_id)
+ items = ActivityLog.objects.for_addons(addons)
+ if filter:
+ items = items.filter(action__in=[i.id for i in filter])
+ pager = amo.utils.paginate(request, items, 20)
+ data = dict(addons=addon_items, pager=pager, activities=activities)
+ return jingo.render(request, 'devhub/addons/activity.html', data)
@dev_required
@@ -280,10 +352,10 @@ def upload_detail(request, uuid, format='html'):
@dev_required
def addons_section(request, addon_id, addon, section, editable=False):
- models = {'basic': addons.forms.AddonFormBasic,
- 'details': addons.forms.AddonFormDetails,
- 'support': addons.forms.AddonFormSupport,
- 'technical': addons.forms.AddonFormTechnical}
+ models = {'basic': addon_forms.AddonFormBasic,
+ 'details': addon_forms.AddonFormDetails,
+ 'support': addon_forms.AddonFormSupport,
+ 'technical': addon_forms.AddonFormTechnical}
if section not in models:
return http.HttpResponseNotFound()
@@ -360,7 +432,8 @@ def version_list(request, addon_id, addon):
data = {'addon': addon,
'versions': versions,
- 'addon_status': amo.STATUS_CHOICES[addon.status] }
+ 'addon_status': amo.STATUS_CHOICES[addon.status],
+ }
return jingo.render(request, 'devhub/addons/versions.html', data)
diff --git a/migrations/95-index-activity.sql b/migrations/95-index-activity.sql
new file mode 100644
index 00000000000..9a32804b4e2
--- /dev/null
+++ b/migrations/95-index-activity.sql
@@ -0,0 +1 @@
+CREATE INDEX created_idx ON log_activity (created);