diff --git a/.bowerrc b/.bowerrc
new file mode 100644
index 00000000000..16f19592776
--- /dev/null
+++ b/.bowerrc
@@ -0,0 +1,3 @@
+{
+ "directory": "static/bower"
+}
diff --git a/bower.json b/bower.json
new file mode 100644
index 00000000000..a40ce2a0a4e
--- /dev/null
+++ b/bower.json
@@ -0,0 +1,28 @@
+{
+ "name": "Kitsune",
+ "description": "Platform for Firefox Help",
+ "homepage": "http://support.mozilla.org/",
+ "private": true,
+ "ignore": [
+ "**/.*",
+ "node_modules",
+ "bower_components",
+ "test",
+ "tests"
+ ],
+ "dependencies": {
+ "ace": "ace-builds#1.1.8",
+ "d3": "d3#3.1.5",
+ "jquery": "jquery#1.10.1",
+ "jquery-ui": "jquery-ui#1.10.2",
+ "mailcheck": "mailcheck#1.1.0",
+ "modernizr": "modernizr#2.6.1",
+ "moment": "moment#2.8.3",
+ "normalize-css": "normalize-css#3.0.2",
+ "nunjucks": "nunjucks#1.0.5",
+ "nwmatcher": "nwmatcher#1.3.4",
+ "selectivizr": "selectivizr#1.0.2",
+ "underscore": "underscore#1.2.1",
+ "react": "~0.13.0"
+ }
+}
diff --git a/kitsune/bundles.py b/kitsune/bundles.py
index baf1855fdf5..5d4cfa7324a 100644
--- a/kitsune/bundles.py
+++ b/kitsune/bundles.py
@@ -1,150 +1,269 @@
# Bundles for JS/CSS Minification
-MINIFY_BUNDLES = {
- 'css': {
- 'common': (
- 'css/normalize.css',
+
+PIPELINE_CSS = {
+ 'common': {
+ 'source_filenames': (
+ 'bower/normalize-css/normalize.css',
'less/main.less',
'less/search.less',
),
- 'community': (
+ 'output_filename': 'common-min.css'
+ },
+ 'community': {
+ 'source_filenames': (
'less/wiki-content.less',
'less/community.less',
'less/select.less',
),
- 'mobile/common': (
- 'css/normalize.css',
+ 'output_filename': 'community-min.css'
+ },
+ 'community-new': {
+ 'source_filenames': (
+ 'css/font-awesome.css',
+ 'less/wiki-content.less',
+ 'less/community-new.less',
+ 'less/select.less',
+ ),
+ 'output_filename': 'community-new-min.css'
+ },
+ 'mobile-common': {
+ 'source_filenames': (
+ 'bower/normalize-css/normalize.css',
'less/mobile/main.less',
),
- 'print': (
+ 'output_filename': 'mobile-common-min.css'
+ },
+ 'print': {
+ 'source_filenames': (
'css/print.css',
),
- # TODO: remove dependency on jquery ui CSS and use our own
- 'jqueryui/jqueryui': (
+ 'output_filename': 'print-min.css',
+ 'extra_context': {
+ 'media': 'print',
+ }
+ },
+ # TODO: remove dependency on jquery ui CSS and use our own
+ 'jqueryui': {
+ 'source_filenames': (
'css/jqueryui/jqueryui.css',
),
- 'forums': (
+ 'output_filename': 'jqueryui-min.css'
+ },
+ 'forums': {
+ 'source_filenames': (
'less/forums.less',
'less/reportabuse.less',
),
- 'questions': (
+ 'output_filename': 'forums-min.css'
+ },
+ 'questions': {
+ 'source_filenames': (
'less/questions.less',
'css/cannedresponses.css',
'less/reportabuse.less',
),
- 'questions.metrics': (
+ 'output_filename': 'questions-min.css'
+ },
+ 'questions.metrics': {
+ 'source_filenames': (
'less/questions.metrics.less',
),
- 'mobile/questions': (
+ 'output_filename': 'questions.metrics-min.css'
+ },
+ 'mobile-questions': {
+ 'source_filenames': (
'less/mobile/questions.less',
),
- 'mobile/aaq': (
+ 'output_filename': 'mobile-questions-min.css'
+ },
+ 'mobile-aaq': {
+ 'source_filenames': (
'less/mobile/aaq.less',
),
- 'rickshaw': (
+ 'output_filename': 'mobile-aaq-min.css'
+ },
+ 'rickshaw': {
+ 'source_filenames': (
'css/jqueryui/jqueryui.css',
'css/rickshaw.css',
'less/rickshaw.sumo.less',
),
- 'mobile/search': (
+ 'output_filename': 'rickshaw-min.css'
+ },
+ 'mobile-search': {
+ 'source_filenames': (
'less/mobile/search.less',
),
- 'wiki': (
+ 'output_filename': 'mobile-search-min.css'
+ },
+ 'wiki': {
+ 'source_filenames': (
'css/users.autocomplete.css',
'css/users.list.css',
'less/wiki.less',
'less/wiki-content.less',
'css/screencast.css',
),
- 'mobile/wiki': (
+ 'output_filename': 'wiki-min.css'
+ },
+ 'mobile-wiki': {
+ 'source_filenames': (
'less/mobile/wiki.less',
'less/wiki-content.less',
),
- 'mobile/wiki-minimal': (
- 'css/normalize.css',
+ 'output_filename': 'mobile-wiki-min.css'
+ },
+ 'mobile-wiki-minimal': {
+ 'source_filenames': (
+ 'bower/normalize-css/normalize.css',
'less/mobile/main.less',
'less/mobile/wiki.less',
'less/wiki-content.less',
),
- 'home': (
+ 'output_filename': 'mobile-wiki-minimal-min.css'
+ },
+ 'home': {
+ 'source_filenames': (
'less/home.less',
),
- 'gallery': (
+ 'output_filename': 'home-min.css'
+ },
+ 'gallery': {
+ 'source_filenames': (
'less/gallery.less',
),
- 'ie': (
+ 'output_filename': 'gallery-min.css'
+ },
+ 'ie': {
+ 'source_filenames': (
'css/ie.css',
'css/ie8.css',
),
- 'ie8': ( # IE 8 needs some specific help.
+ 'output_filename': 'ie-min.css'
+ },
+ 'ie8': {
+ 'source_filenames': ( # IE 8 needs some specific help.
'css/ie8.css',
),
- 'customercare': (
+ 'output_filename': 'ie8-min.css'
+ },
+ 'customercare': {
+ 'source_filenames': (
'less/customercare.less',
),
- 'users': (
+ 'output_filename': 'customercare-min.css'
+ },
+ 'users': {
+ 'source_filenames': (
'less/users.less',
'less/reportabuse.less',
),
- 'mobile/users': (
+ 'output_filename': 'users-min.css'
+ },
+ 'mobile-users': {
+ 'source_filenames': (
'less/mobile/users.less',
),
- 'monitor': (
+ 'output_filename': 'mobile-users-min.css'
+ },
+ 'monitor': {
+ 'source_filenames': (
'css/monitor.css',
),
- 'messages': (
+ 'output_filename': 'monitor-min.css'
+ },
+ 'messages': {
+ 'source_filenames': (
'css/users.autocomplete.css',
'less/messages.less',
),
- 'mobile/messages': (
+ 'output_filename': 'messages-min.css'
+ },
+ 'mobile-messages': {
+ 'source_filenames': (
'less/mobile/messages.less',
),
- 'products': (
+ 'output_filename': 'mobile-messages-min.css'
+ },
+ 'products': {
+ 'source_filenames': (
'less/products.less',
),
- 'mobile/products': (
+ 'output_filename': 'products-min.css'
+ },
+ 'mobile-products': {
+ 'source_filenames': (
'less/mobile/products.less',
),
- 'groups': (
+ 'output_filename': 'mobile-products-min.css'
+ },
+ 'groups': {
+ 'source_filenames': (
'css/users.autocomplete.css',
'css/users.list.css',
'css/groups.css',
'css/wiki_syntax.css',
),
- 'kpi.dashboard': (
+ 'output_filename': 'groups-min.css'
+ },
+ 'kpi.dashboard': {
+ 'source_filenames': (
'less/kpi.dashboard.less',
),
- 'locale-switcher': (
+ 'output_filename': 'kpi.dashboard-min.css'
+ },
+ 'locale-switcher': {
+ 'source_filenames': (
'less/locale-switcher.less',
),
- 'mobile/locale-switcher': (
+ 'output_filename': 'locale-switcher-min.css'
+ },
+ 'mobile-locale-switcher': {
+ 'source_filenames': (
'less/mobile/locales.less',
),
- 'kbdashboards': (
+ 'output_filename': 'mobile-locale-switcher-min.css'
+ },
+ 'kbdashboards': {
+ 'source_filenames': (
'less/kbdashboards.less',
),
- 'landings/get-involved': (
+ 'output_filename': 'kbdashboards-min.css'
+ },
+ 'landings-get-involved': {
+ 'source_filenames': (
'less/landings/get-involved.less',
),
- 'mobile/landings/get-involved': (
+ 'output_filename': 'landings-get-involved-min.css'
+ },
+ 'mobile-landings-get-involved': {
+ 'source_filenames': (
'less/mobile/landings/get-involved.less',
),
- 'badges': (
+ 'output_filename': 'mobile-landings-get-involved-min.css'
+ },
+ 'badges': {
+ 'source_filenames': (
'less/badges.less',
),
- },
- 'js': {
- 'common': (
+ 'output_filename': 'badges-min.css'
+ }
+}
+
+PIPELINE_JS = {
+ 'common': {
+ 'source_filenames': (
'js/i18n.js',
- 'js/libs/underscore.js',
- 'js/libs/moment-2.8.3.js',
- 'js/libs/jquery-1.10.1.min.js',
- 'js/libs/jquery.migrate.js',
+ 'bower/underscore/underscore.js',
+ 'bower/moment/moment.js',
+ 'bower/jquery/jquery.min.js',
+ 'bower/jquery/jquery-migrate.js',
'js/libs/jquery.cookie.js',
'js/libs/jquery.placeholder.js',
'js/templates/macros.js',
'js/templates/search-results-list.js',
'js/templates/search-results.js',
- 'js/libs/nunjucks-slim.js',
+ 'bower/nunjucks/browser/nunjucks-slim.js',
'js/nunjucks.js',
'js/cached_xhr.js',
'js/search_utils.js',
@@ -153,38 +272,75 @@
'js/kbox.js',
'js/main.js',
'js/format.js',
- 'js/libs/modernizr-2.6.1.js',
+ 'bower/modernizr/modernizr.js',
'js/geoip-locale.js',
- 'js/libs/mailcheck.js',
+ 'bower/mailcheck/src/mailcheck.js',
'js/ui.js',
'js/analytics.js',
'js/surveygizmo.js',
'js/instant_search.js',
),
- 'community': (
- 'js/libs/jquery-1.10.1.min.js',
+ 'output_filename': 'common-min.js'
+ },
+ 'community': {
+ 'source_filenames': (
+ 'bower/jquery/jquery.min.js',
+ 'bower/jquery/jquery-migrate.js',
'js/community.js',
'js/select.js',
),
- 'mobile/common': (
+ 'output_filename': 'community-min.js'
+ },
+ 'community-new': {
+ 'source_filenames': (
+ # This uses the minified version because it is optimized to leave
+ # out lots of debug stuff, so it is significantly smaller than
+ # just minifying react.js.
+ # TODO: Figure out how to include the full sized version in dev.
+ 'bower/react/react.min.js',
+ 'js/community-new.browserify.js',
+ ),
+ 'output_filename': 'community-new-min.js'
+ },
+ 'mobile-common': {
+ 'source_filenames': (
'js/i18n.js',
- 'js/libs/underscore.js',
- 'js/libs/jquery-1.10.1.min.js',
- 'js/libs/jquery.migrate.js',
- 'js/libs/modernizr-2.6.1.js',
+ 'bower/underscore/underscore.js',
+ 'bower/jquery/jquery.min.js',
+ 'bower/jquery/jquery-migrate.js',
+ 'bower/modernizr/modernizr.js',
'js/browserdetect.js',
'js/aaq.js',
'js/mobile/ui.js',
'js/analytics.js',
),
- 'ie6-8': (
- 'js/libs/nwmatcher-1.2.5.js',
- 'js/libs/selectivizr-1.0.2.js',
- ),
- 'libs/jqueryui': (
- 'js/libs/jqueryui.js',
+ 'output_filename': 'mobile-common-min.js'
+ },
+ 'ie6-8': {
+ 'source_filenames': (
+ 'bower/nwmatcher/src/nwmatcher.js',
+ 'bower/selectivizr/selectivizr.js',
),
- 'questions': (
+ 'output_filename': 'ie6-8-min.js'
+ },
+ 'jqueryui': {
+ 'source_filenames': (
+ 'bower/jquery/jquery.ui.core.js',
+ 'bower/jquery/jquery.ui.widget.js',
+ 'bower/jquery/jquery.ui.mouse.js',
+ 'bower/jquery/jquery.ui.position.js',
+ 'bower/jquery/jquery.ui.sortable.js',
+ 'bower/jquery/jquery.ui.accordion.js',
+ 'bower/jquery/jquery.ui.autocomplete.js',
+ 'bower/jquery/jquery.ui.datepicker.js',
+ 'bower/jquery/jquery.ui.menu.js',
+ 'bower/jquery/jquery.ui.slider.js',
+ 'bower/jquery/jquery.ui.tabs.js',
+ ),
+ 'output_filename': 'jqueryui-min.js'
+ },
+ 'questions': {
+ 'source_filenames': (
'js/markup.js',
'js/ajaxvote.js',
'js/ajaxpreview.js',
@@ -199,34 +355,58 @@
'js/libs/jquery.ajaxupload.js',
'js/upload.js',
),
- 'questions.metrics': (
+ 'output_filename': 'questions-min.js'
+ },
+ 'questions.metrics': {
+ 'source_filenames': (
'js/questions.metrics-dashboard.js',
),
- 'mobile/questions': (
+ 'output_filename': 'questions.metrics-min.js'
+ },
+ 'mobile-questions': {
+ 'source_filenames': (
'js/mobile/questions.js',
'js/questions.metrics.js',
),
- 'mobile/aaq': (
+ 'output_filename': 'mobile-questions-min.js'
+ },
+ 'mobile-aaq': {
+ 'source_filenames': (
'js/aaq.js',
'js/mobile/aaq.js',
),
- 'products': (
+ 'output_filename': 'mobile-aaq-min.js'
+ },
+ 'products': {
+ 'source_filenames': (
'js/products.js',
),
- 'search': (
+ 'output_filename': 'products-min.js'
+ },
+ 'search': {
+ 'source_filenames': (
'js/search.js',
),
- 'forums': (
+ 'output_filename': 'search-min.js'
+ },
+ 'forums': {
+ 'source_filenames': (
'js/markup.js',
'js/ajaxpreview.js',
'js/forums.js',
'js/reportabuse.js',
),
- 'gallery': (
+ 'output_filename': 'forums-min.js'
+ },
+ 'gallery': {
+ 'source_filenames': (
'js/libs/jquery.ajaxupload.js',
'js/gallery.js',
),
- 'wiki': (
+ 'output_filename': 'gallery-min.js'
+ },
+ 'wiki': {
+ 'source_filenames': (
'js/markup.js',
'js/libs/django/urlify.js',
'js/libs/django/prepopulate.js',
@@ -244,15 +424,20 @@
'js/editable.js',
'js/wiki.metrics.js',
),
- 'rickshaw': (
- 'js/libs/jqueryui.js',
- 'js/libs/d3.js',
+ 'output_filename': 'wiki-min.js'
+ },
+ 'rickshaw': {
+ 'source_filenames': (
+ 'bower/d3/d3.js',
'js/libs/d3.layout.min.js',
'js/libs/rickshaw.js',
'js/rickshaw_utils.js',
),
- 'mobile/wiki': (
- 'js/libs/underscore.js',
+ 'output_filename': 'rickshaw-min.js'
+ },
+ 'mobile-wiki': {
+ 'source_filenames': (
+ 'bower/underscore/underscore.js',
'js/libs/jquery.cookie.js',
'js/libs/jquery.lazyload.js',
'js/browserdetect.js',
@@ -261,12 +446,15 @@
'js/mobile/wiki.js',
'js/wiki.metrics.js',
),
- 'mobile/wiki-minimal': (
+ 'output_filename': 'mobile-wiki-min.js'
+ },
+ 'mobile-wiki-minimal': {
+ 'source_filenames': (
'js/i18n.js',
- 'js/libs/underscore.js',
- 'js/libs/jquery-1.10.1.min.js',
- 'js/libs/jquery.migrate.js',
- 'js/libs/modernizr-2.6.1.js',
+ 'bower/underscore/underscore.js',
+ 'bower/jquery/jquery.min.js',
+ 'bower/jquery/jquery-migrate.js',
+ 'bower/modernizr/modernizr.js',
'js/browserdetect.js',
'js/mobile/ui.js',
'js/analytics.js',
@@ -277,33 +465,54 @@
'js/mobile/wiki.js',
'js/wiki.metrics.js',
),
- 'wiki.history': (
+ 'output_filename': 'mobile-wiki-minimal-min.js'
+ },
+ 'wiki.history': {
+ 'source_filenames': (
'js/historycharts.js',
),
- 'wiki.diff': (
+ 'output_filename': 'wiki.history-min.js'
+ },
+ 'wiki.diff': {
+ 'source_filenames': (
'js/libs/diff_match_patch_uncompressed.js',
'js/diff.js',
),
- 'wiki.editor': (
- 'js/libs/ace/ace.js',
- 'js/libs/ace/ext-language_tools.js',
+ 'output_filename': 'wiki.diff-min.js'
+ },
+ 'wiki.editor': {
+ 'source_filenames': (
+ 'bower/ace/src/ace.js',
+ 'bower/ace/src/ext-language_tools.js',
'js/ace.mode-sumo.js',
),
- 'wiki.dashboard': (
+ 'output_filename': 'wiki.editor-min.js'
+ },
+ 'wiki.dashboard': {
+ 'source_filenames': (
'js/wiki.dashboard.js',
),
- 'customercare': (
+ 'output_filename': 'wiki.dashboard-min.js'
+ },
+ 'customercare': {
+ 'source_filenames': (
'js/libs/jquery.cookie.js',
'js/libs/jquery.bullseye-1.0.min.js',
'js/libs/twitter-text.js',
'js/customercare.js',
'js/users.js',
),
- 'users': (
+ 'output_filename': 'customercare-min.js'
+ },
+ 'users': {
+ 'source_filenames': (
'js/users.js',
'js/reportabuse.js',
),
- 'messages': (
+ 'output_filename': 'users-min.js'
+ },
+ 'messages': {
+ 'source_filenames': (
'js/markup.js',
'js/libs/jquery.autoresize.js',
'js/libs/jquery.tokeninput.js',
@@ -311,19 +520,29 @@
'js/ajaxpreview.js',
'js/messages.js',
),
- 'mobile/messages': (
+ 'output_filename': 'messages-min.js'
+ },
+ 'mobile-messages': {
+ 'source_filenames': (
'js/libs/jquery.tokeninput.js',
'js/users.autocomplete.js',
),
- 'groups': (
+ 'output_filename': 'mobile-messages-min.js'
+ },
+ 'groups': {
+ 'source_filenames': (
'js/libs/jquery.tokeninput.js',
'js/users.autocomplete.js',
'js/markup.js',
'js/groups.js',
'js/editable.js',
),
- 'kpi.dashboard': (
+ 'output_filename': 'groups-min.js'
+ },
+ 'kpi.dashboard': {
+ 'source_filenames': (
'js/kpi.dashboard.js',
),
- },
+ 'output_filename': 'kpi.dashboard-min.js'
+ }
}
diff --git a/kitsune/community/api.py b/kitsune/community/api.py
new file mode 100644
index 00000000000..c874714d3ba
--- /dev/null
+++ b/kitsune/community/api.py
@@ -0,0 +1,180 @@
+from collections import defaultdict
+from datetime import datetime, timedelta
+
+from elasticutils import F
+from rest_framework import views, fields
+from rest_framework.response import Response
+
+from kitsune.questions.models import AnswerMetricsMappingType
+from kitsune.users.models import UserMappingType
+
+
+# This should be the higher than the max number of contributors for a
+# section. There isn't a way to tell ES to just return everything.
+BIG_NUMBER = 10000
+
+
+class TopContributorsQuestions(views.APIView):
+
+ def get(self, request):
+ return Response(self.get_data(request))
+
+ def get_filters(self):
+ f = F(by_asker=False)
+
+ self.filter_values = {
+ 'startdate': (datetime.now() - timedelta(days=90)).strftime('%Y-%m-%d'),
+ 'enddate': datetime.now().strftime('%Y-%m-%d'),
+ }
+ # request.GET is a multidict, so simple `.update(request.GET)` causes
+ # everything to be a list. This converts it into a plain single dict.
+ self.filter_values.update(dict(self.request.GET.items()))
+
+ for key, value in self.filter_values.items():
+ filter_method = getattr(self, 'filter_' + key, lambda v: F())
+ f &= filter_method(value)
+
+ return f
+
+ def filter_startdate(self, value):
+ date = fields.DateField().from_native(value)
+ dt = datetime.combine(date, datetime.min.time())
+ return F(created__gte=dt)
+
+ def filter_enddate(self, value):
+ date = fields.DateField().from_native(value)
+ dt = datetime.combine(date, datetime.max.time())
+ return F(created__lte=dt)
+
+ def filter_username(self, value):
+ username_lower = value.lower()
+
+ username_filter = (
+ F(iusername__prefix=username_lower) |
+ F(idisplay_name__prefix=username_lower) |
+ F(itwitter_usernames__prefix=username_lower))
+
+ users = UserMappingType.reshape(
+ UserMappingType
+ .search()
+ .filter(username_filter)
+ .values_dict('id')
+ [:BIG_NUMBER])
+
+ return F(creator_id__in=[u['id'] for u in users])
+
+ def filter_locale(self, value):
+ return F(locale=value)
+
+ def get_data(self, request):
+ # So filters can use the request.
+ self.request = request
+
+ # This is the base of all the metrics. Each metric branches off from
+ # this to get a particular metric type, since we can't do Aggregates.
+ query = AnswerMetricsMappingType.search()
+ base_filter = self.get_filters()
+
+ # This branch is to get the total number of answers for each user.
+ answer_query = (
+ query
+ .filter(base_filter)
+ .facet('creator_id', filtered=True, size=BIG_NUMBER))
+
+ # This branch gets the number of answers that are solutions for each user.
+ solutions_filter = base_filter & F(is_solution=True)
+ solutions_query = (
+ query
+ .filter(solutions_filter)
+ .facet('creator_id', filtered=True, size=BIG_NUMBER))
+
+ # This branch gets the number of helpful votes across all answers for
+ # each user. It is a raw facet because elasticutils only supports the
+ # term facet type in non-raw facets. Because it is raw facet, we have
+ # to also put the filter in the facet ourselves.
+ helpful_query = (
+ query
+ .facet_raw(
+ creator_id={
+ 'terms_stats': {
+ 'key_field': 'creator_id',
+ 'value_field': 'helpful_count',
+ },
+ 'facet_filter': query._process_filters(base_filter.filters),
+ }))
+
+ # Collect three lists of objects that correlates users and the appropriate metric count
+ creator_answer_counts = answer_query.facet_counts()['creator_id']['terms']
+ creator_solutions_counts = solutions_query.facet_counts()['creator_id']['terms']
+ creator_helpful_counts = helpful_query.facet_counts()['creator_id']['terms']
+
+ # Combine all the metric types into one big list.
+ combined = defaultdict(lambda: {
+ 'answer_count': 0,
+ 'solution_count': 0,
+ 'helpful_vote_count': 0,
+ })
+
+ for d in creator_answer_counts:
+ combined[d['term']]['user_id'] = d['term']
+ combined[d['term']]['answer_count'] = d['count']
+
+ for d in creator_solutions_counts:
+ combined[d['term']]['user_id'] = d['term']
+ combined[d['term']]['solution_count'] = d['count']
+
+ for d in creator_helpful_counts:
+ combined[d['term']]['user_id'] = d['term']
+ # Since this is a term_stats filter, not just a term filter, it is total, not count.
+ combined[d['term']]['helpful_vote_count'] = d['total']
+
+ # Sort by answer count, and get just the ids into a list.
+ top_contributors = combined.values()
+ top_contributors.sort(key=lambda d: d['answer_count'], reverse=True)
+ user_ids = [c['user_id'] for c in top_contributors]
+ full_count = len(user_ids)
+
+ # Paginate those user ids.
+ try:
+ page = int(self.request.GET.get('page', 1))
+ except ValueError:
+ page = 1
+ count = 10
+ page_start = (page - 1) * count
+ page_end = page_start + count
+ user_ids = user_ids[page_start:page_end]
+
+ # Get full user objects for every id on this page.
+ users = UserMappingType.reshape(
+ UserMappingType
+ .search()
+ .filter(id__in=user_ids)
+ .values_dict('id', 'username', 'display_name', 'avatar', 'last_contribution_date')
+ [:count])
+
+ # For ever user object found, mix in the metrics counts for that user,
+ # and then reshape the data to make more sense to clients.
+ data = []
+ for u in users:
+ d = combined[u['id']]
+ d['user'] = u
+ d['last_contribution_date'] = d['user'].get('last_contribution_date', None)
+ if 'user_id' in d:
+ del d['user_id']
+ del d['user']['id']
+ if 'last_contribution_date' in d['user']:
+ del d['user']['last_contribution_date']
+ data.append(d)
+
+ # One last sort, since ES didn't return the users in any particular order.
+ data.sort(key=lambda d: d['answer_count'], reverse=True)
+
+ # Add ranks to the objects.
+ for i, contributor in enumerate(data, 1):
+ contributor['rank'] = page_start + i
+
+ return {
+ 'results': data,
+ 'count': full_count,
+ 'filters': self.filter_values,
+ }
diff --git a/kitsune/community/static/js/TopContributors.jsx b/kitsune/community/static/js/TopContributors.jsx
new file mode 100644
index 00000000000..c91c34838a8
--- /dev/null
+++ b/kitsune/community/static/js/TopContributors.jsx
@@ -0,0 +1,328 @@
+/* jshint esnext: true */
+/* globals React:false */
+export class CommunityResults extends React.Component {
+ render() {
+ var filters = this.props.data.filters;
+ var results = this.props.data.results;
+ var fullCount = this.props.data.count;
+
+ var setfilters = this.props.setFilters;
+ var pageCount = Math.ceil(fullCount / Math.max(results.length, 1));
+
+ return
| t |