diff --git a/src/olympia/addons/admin.py b/src/olympia/addons/admin.py index 5e693fe24f..98482fe96e 100644 --- a/src/olympia/addons/admin.py +++ b/src/olympia/addons/admin.py @@ -15,7 +15,7 @@ from django.http.response import ( from django.shortcuts import get_object_or_404 from django.urls import resolve from django.utils.encoding import force_text -from django.utils.html import format_html +from django.utils.html import format_html, format_html_join from django.utils.translation import ugettext, ugettext_lazy as _ import olympia.core.logger @@ -147,16 +147,22 @@ class FileInline(admin.TabularInline): class AddonAdmin(admin.ModelAdmin): class Media: - css = {'all': ('css/admin/l10n.css', 'css/admin/pagination.css')} + css = { + 'all': ( + 'css/admin/l10n.css', + 'css/admin/pagination.css', + 'css/admin/addons.css', + ) + } js = ('admin/js/jquery.init.js', 'js/admin/l10n.js', 'js/admin/recalc_hash.js') - exclude = ('authors',) list_display = ( '__str__', 'type', 'guid', 'status', 'average_rating', + 'authors_links', 'reviewer_links', ) list_filter = ('type', 'status') @@ -243,8 +249,12 @@ class AddonAdmin(admin.ModelAdmin): ) actions = ['git_extract_action'] - def queryset(self, request): - return models.Addon.unfiltered + def get_queryset(self, request): + return ( + Addon.unfiltered.all() + .only_translations() + .transform(Addon.attach_all_authors) + ) def get_urls(self): def wrap(view): @@ -263,13 +273,45 @@ class AddonAdmin(admin.ModelAdmin): ] return custom_urlpatterns + urlpatterns + def authors_links(self, obj): + # Note: requires .transform(Addon.attach_all_authors) to have been + # applied to fill all_authors property and role on each user in it. + authors = obj.all_authors + return ( + format_html( + '', + format_html_join( + '', + '
  • {} ({}{})
  • ', + ( + ( + urljoin( + settings.EXTERNAL_SITE_URL, + reverse( + 'admin:users_userprofile_change', args=(author.pk,) + ), + ), + author.email, + dict(amo.AUTHOR_CHOICES_UNFILTERED)[author.role], + ', Not listed' if author.listed is False else '', + ) + for author in authors + ), + ), + ) + if authors + else '-' + ) + + authors_links.short_description = _('Authors') + def total_ratings_link(self, obj): return related_content_link( obj, Rating, 'addon', related_manager='without_replies', - count=obj.total_ratings, + text=obj.total_ratings, ) total_ratings_link.short_description = _(u'Ratings') @@ -305,10 +347,8 @@ class AddonAdmin(admin.ModelAdmin): if lookup_field != 'pk': addon = None try: - if lookup_field == 'slug': - addon = self.queryset(request).all().get(slug=object_id) - elif lookup_field == 'guid': - addon = self.queryset(request).get(guid=object_id) + if lookup_field in ('slug', 'guid'): + addon = self.get_queryset(request).get(**{lookup_field: object_id}) except Addon.DoesNotExist: raise http.Http404 # Don't get in an infinite loop if addon.slug.isdigit(). diff --git a/src/olympia/addons/models.py b/src/olympia/addons/models.py index a81f54bdcf..8a6c7447d2 100644 --- a/src/olympia/addons/models.py +++ b/src/olympia/addons/models.py @@ -1158,27 +1158,67 @@ class Addon(OnChangeMixin, ModelBase): version.addon = addon @staticmethod - def attach_listed_authors(addons, addon_dict=None): + def _attach_authors( + addons, + addon_dict=None, + manager='objects', + listed=True, + to_attr='listed_authors', + ): + # It'd be nice if this could be done with something like + # qs.prefetch_related( + # Prefetch('authors', queryset=UserProfile.objects.annotate( + # role=F('addonuser__role'), listed=F('addonuser__listed')))) + # instead, but that doesn't work because the prefetch queryset is + # making a different join for addonuser than the one used by the + # manytomanyfield, so the results are completely wrong when there are + # more than one add-on. Also this wouldn't let us customize the + # AddonUser manager to include/exclude deleted roles. + # So instead, we do it via AddonUser, copy the properties on the users + # and throw away the AddonUser instances afterwards. if addon_dict is None: addon_dict = {addon.id: addon for addon in addons} + filters = {'addon__in': addons} + if listed is not None: + filters['listed'] = listed + addonuser_qs = getattr(AddonUser, manager).all() addonuser_qs = ( - AddonUser.objects.filter(addon__in=addons, listed=True) + addonuser_qs.filter(**filters) .order_by('addon_id', 'position') .select_related('user') ) seen = set() groupby = itertools.groupby(addonuser_qs, key=lambda u: u.addon_id) for addon_id, addonusers in groupby: - addon_dict[addon_id].listed_authors = [au.user for au in addonusers] + authors = [] + for addonuser in addonusers: + setattr(addonuser.user, 'role', addonuser.role) + setattr(addonuser.user, 'listed', addonuser.listed) + authors.append(addonuser.user) + setattr(addon_dict[addon_id], to_attr, authors) seen.add(addon_id) - # set listed_authors to empty list on addons without listed authors. + # set authors to empty list on addons without any. [ - setattr(addon, 'listed_authors', []) + setattr(addon, to_attr, []) for addon in addon_dict.values() if addon.id not in seen ] + @staticmethod + def attach_listed_authors(addons, addon_dict=None): + Addon._attach_authors(addons, addon_dict=addon_dict) + + @staticmethod + def attach_all_authors(addons, addon_dict=None): + Addon._attach_authors( + addons, + addon_dict=addon_dict, + manager='unfiltered', + listed=None, + to_attr='all_authors', + ) + @staticmethod def attach_previews(addons, addon_dict=None, no_transforms=False): if addon_dict is None: diff --git a/src/olympia/addons/tests/test_admin.py b/src/olympia/addons/tests/test_admin.py index a1be78f6b0..61c8db30f3 100644 --- a/src/olympia/addons/tests/test_admin.py +++ b/src/olympia/addons/tests/test_admin.py @@ -154,6 +154,21 @@ class TestAddonAdmin(TestCase): assert b'Reviewer Tools (listed)' in response.content assert b'Reviewer Tools (unlisted)' in response.content + def test_list_queries(self): + addon_factory(guid='@foo') + addon_factory(guid='@bar') + addon_factory(guid='@xyz') + user = user_factory(email='someone@mozilla.com') + self.grant_permission(user, 'Addons:Edit') + self.client.login(email=user.email) + + with self.assertNumQueries(12): + # FIXME: explain each query, lower count (#16132 should fix it + # the scaling, currently there is one query per add-on for the + # unlisted reviewer exists() query) + response = self.client.get(self.list_url, follow=True) + assert response.status_code == 200 + def test_can_edit_with_addons_edit_permission(self): addon = addon_factory(guid='@foo') self.detail_url = reverse('admin:addons_addon_change', args=(addon.pk,)) @@ -499,16 +514,18 @@ class TestAddonAdmin(TestCase): self.grant_permission(user, 'Addons:Edit') self.grant_permission(user, 'Admin:Advanced') self.client.login(email=user.email) - with self.assertNumQueries(26): + with self.assertNumQueries(20): # It's very high because most of AddonAdmin is unoptimized but we # don't want it unexpectedly increasing. + # FIXME: explain each query response = self.client.get(self.detail_url, follow=True) assert response.status_code == 200 assert addon.guid in response.content.decode('utf-8') version_factory(addon=addon) - with self.assertNumQueries(26): - # confirm it scales + with self.assertNumQueries(20): + # Confirm it scales + # FIXME: explain each query response = self.client.get(self.detail_url, follow=True) assert response.status_code == 200 assert addon.guid in response.content.decode('utf-8') diff --git a/src/olympia/addons/tests/test_models.py b/src/olympia/addons/tests/test_models.py index e261c02d51..74abf2b855 100644 --- a/src/olympia/addons/tests/test_models.py +++ b/src/olympia/addons/tests/test_models.py @@ -489,7 +489,7 @@ class TestAddonModels(TestCase): def test_transformer(self): author = UserProfile.objects.get(pk=55021) new_author = AddonUser.objects.create( - addon_id=3615, user=user_factory(), listed=True + addon_id=3615, user=user_factory(username='new_author'), listed=True ).user # make the new_author a deleted author of another addon AddonUser.objects.create( @@ -498,12 +498,24 @@ class TestAddonModels(TestCase): # Deleted, so shouldn't show up below. AddonUser.objects.create( addon_id=3615, - user=user_factory(), + user=user_factory(username='deleted_author'), listed=True, role=amo.AUTHOR_ROLE_DELETED, - ).user + ) + # Not listed, should not show up. + AddonUser.objects.create( + addon_id=3615, + user=user_factory(username='not_listed_author'), + listed=False, + role=amo.AUTHOR_ROLE_OWNER, + ) + # Different author on another add-on - should not show up + AddonUser.objects.create(addon=addon_factory(), user=user_factory()) + # First author, but on another add-on, not deleted - should not show up + AddonUser.objects.create(addon=addon_factory(), user=author) - addon = Addon.objects.get(pk=3615) + # Force evaluation of the queryset and test a single add-on + addon = list(Addon.objects.all().order_by('pk'))[0] # If the transformer works then we won't have any more queries. with self.assertNumQueries(0): @@ -515,7 +527,55 @@ class TestAddonModels(TestCase): # repeat to check the position ordering works author.addonuser_set.filter(addon=addon).update(position=1) addon = Addon.objects.get(pk=3615) - assert [u.pk for u in addon.listed_authors] == [new_author.pk, author.pk] + with self.assertNumQueries(0): + assert [u.pk for u in addon.listed_authors] == [new_author.pk, author.pk] + + def test_transformer_all_authors(self): + author = UserProfile.objects.get(pk=55021) + new_author = AddonUser.objects.create( + addon_id=3615, user=user_factory(username='new_author'), listed=True + ).user + # make the new_author a deleted author of another addon + AddonUser.objects.create( + addon=addon_factory(), user=new_author, role=amo.AUTHOR_ROLE_DELETED + ) + # Deleted, so it should show up cause we're looking at *all* authors + # explicitly. + deleted_author = AddonUser.objects.create( + addon_id=3615, + user=user_factory(username='deleted_author'), + listed=True, + role=amo.AUTHOR_ROLE_DELETED, + ).user + # Not listed, should also show up. + not_listed_author = AddonUser.objects.create( + addon_id=3615, + user=user_factory(username='not_listed_author'), + listed=False, + role=amo.AUTHOR_ROLE_OWNER, + ).user + # Different author on another add-on - should not show up + AddonUser.objects.create(addon=addon_factory(), user=user_factory()) + # First author, but on another add-on, not deleted - should not show up + AddonUser.objects.create(addon=addon_factory(), user=author) + + # Force evaluation of the queryset and test a single add-on + addon = list(Addon.objects.transform(Addon.attach_all_authors).order_by('pk'))[ + 0 + ] + + # If the transformer works then we won't have any more queries. + with self.assertNumQueries(0): + assert [u.pk for u in addon.all_authors] == [ + author.pk, + new_author.pk, + deleted_author.pk, + not_listed_author.pk, + ] + for user in addon.all_authors: + addonuser = AddonUser.unfiltered.filter(user=user, addon=addon).get() + assert user.role == addonuser.role + assert user.listed == addonuser.listed def _delete(self, addon_id): """Test deleting add-ons.""" diff --git a/src/olympia/users/admin.py b/src/olympia/users/admin.py index 17f7859a98..e706644db9 100644 --- a/src/olympia/users/admin.py +++ b/src/olympia/users/admin.py @@ -4,6 +4,7 @@ from django import http from django.conf.urls import url from django.contrib import admin, messages from django.contrib.admin.utils import unquote +from django.db.models import Count, Q from django.db.utils import IntegrityError from django.http import ( Http404, @@ -21,7 +22,7 @@ from olympia import amo from olympia.abuse.models import AbuseReport from olympia.access import acl from olympia.activity.models import ActivityLog, UserLog -from olympia.addons.models import Addon +from olympia.addons.models import Addon, AddonUser from olympia.amo.admin import CommaSearchInAdminMixin from olympia.api.models import APIKey, APIKeyConfirmation from olympia.bandwagon.models import Collection @@ -66,9 +67,9 @@ class UserAdmin(CommaSearchInAdminMixin, admin.ModelAdmin): 'last_login_ip', 'known_ip_adresses', 'last_known_activity_time', - 'ratings_created', - 'collections_created', - 'addons_created', + 'ratings_authorship', + 'collections_authorship', + 'addons_authorship', 'activity', 'abuse_reports_by_this_user', 'abuse_reports_for_this_user', @@ -104,7 +105,13 @@ class UserAdmin(CommaSearchInAdminMixin, admin.ModelAdmin): ), ( 'Content', - {'fields': ('addons_created', 'collections_created', 'ratings_created')}, + { + 'fields': ( + 'addons_authorship', + 'collections_authorship', + 'ratings_authorship', + ) + }, ), ( 'Abuse Reports', @@ -360,20 +367,37 @@ class UserAdmin(CommaSearchInAdminMixin, admin.ModelAdmin): has_active_api_key.boolean = True - def collections_created(self, obj): + def collections_authorship(self, obj): return related_content_link(obj, Collection, 'author') - collections_created.short_description = _('Collections') + collections_authorship.short_description = _('Collections') - def addons_created(self, obj): - return related_content_link(obj, Addon, 'authors', related_manager='unfiltered') + def addons_authorship(self, obj): + counts = ( + AddonUser.unfiltered.filter(user=obj) + .order_by() + .aggregate( + active_role=Count('role', filter=~Q(role=amo.AUTHOR_ROLE_DELETED)), + deleted_role=Count('role', filter=Q(role=amo.AUTHOR_ROLE_DELETED)), + ) + ) + return related_content_link( + obj, + Addon, + 'authors', + text=format_html( + '{} (active role), {} (deleted role)', + counts['active_role'], + counts['deleted_role'], + ), + ) - addons_created.short_description = _('Addons') + addons_authorship.short_description = _('Addons') - def ratings_created(self, obj): + def ratings_authorship(self, obj): return related_content_link(obj, Rating, 'user') - ratings_created.short_description = _('Ratings') + ratings_authorship.short_description = _('Ratings') def activity(self, obj): return related_content_link(obj, ActivityLog, 'user') diff --git a/src/olympia/users/tests/test_admin.py b/src/olympia/users/tests/test_admin.py index e0c4ea3f02..b82e1b9b57 100644 --- a/src/olympia/users/tests/test_admin.py +++ b/src/olympia/users/tests/test_admin.py @@ -557,11 +557,11 @@ class TestUserAdmin(TestCase): link = pq(result)('a')[0] return link.attrib['href'], link.text - def test_collections_created(self): + def test_collections_authorship(self): Collection.objects.create() Collection.objects.create(author=self.user) Collection.objects.create(author=self.user, listed=False) - url, text = self._call_related_content_method('collections_created') + url, text = self._call_related_content_method('collections_authorship') expected_url = ( reverse('admin:bandwagon_collection_changelist') + '?author=%d' % self.user.pk @@ -569,7 +569,7 @@ class TestUserAdmin(TestCase): assert url == expected_url assert text == '2' - def test_addons_created(self): + def test_addons_authorship(self): addon_factory() another_user = user_factory() addon_factory(users=[self.user, another_user]) @@ -578,14 +578,21 @@ class TestUserAdmin(TestCase): addon_factory( users=[self.user], version_kw={'channel': amo.RELEASE_CHANNEL_UNLISTED} ) - url, text = self._call_related_content_method('addons_created') + # This next add-on shouldn't be counted. + addon_where_user_has_deleted_role = addon_factory( + users=[self.user, another_user] + ) + addon_where_user_has_deleted_role.addonuser_set.filter(user=self.user).update( + role=amo.AUTHOR_ROLE_DELETED + ) + url, text = self._call_related_content_method('addons_authorship') expected_url = ( reverse('admin:addons_addon_changelist') + '?authors=%d' % self.user.pk ) assert url == expected_url - assert text == '4' + assert text == '4 (active role), 1 (deleted role)' - def test_ratings_created(self): + def test_ratings_authorship(self): Rating.objects.create(addon=addon_factory(), user=self.user) dummy_addon = addon_factory() Rating.objects.create( @@ -599,7 +606,7 @@ class TestUserAdmin(TestCase): Rating.objects.create( addon=dummy_addon, user=user_factory(), ip_address='255.255.0.0' ) - url, text = self._call_related_content_method('ratings_created') + url, text = self._call_related_content_method('ratings_authorship') expected_url = ( reverse('admin:ratings_rating_changelist') + '?user=%d' % self.user.pk ) diff --git a/src/olympia/zadmin/admin.py b/src/olympia/zadmin/admin.py index 089a016cea..30b5679cc4 100644 --- a/src/olympia/zadmin/admin.py +++ b/src/olympia/zadmin/admin.py @@ -11,7 +11,7 @@ from . import models def related_content_link( - obj, related_class, related_field, related_manager='objects', count=None + obj, related_class, related_field, related_manager='objects', text=None ): """ Return a link to the admin changelist for the instances of related_class @@ -20,11 +20,11 @@ def related_content_link( url = 'admin:{}_{}_changelist'.format( related_class._meta.app_label, related_class._meta.model_name ) - queryset = getattr(related_class, related_manager).filter(**{related_field: obj}) - if count is None: - count = queryset.count() + if text is None: + qs = getattr(related_class, related_manager).filter(**{related_field: obj}) + text = qs.count() return format_html( - '{}', reverse(url), related_field, obj.pk, count + '{}', reverse(url), related_field, obj.pk, text ) diff --git a/static/css/admin/addons.css b/static/css/admin/addons.css new file mode 100644 index 0000000000..b47245096e --- /dev/null +++ b/static/css/admin/addons.css @@ -0,0 +1,8 @@ +#changelist ul { + margin-left: 0; + padding-left: 0; +} + +#changelist li { + list-style-type: none; +}