diff --git a/bower.json b/bower.json index 9cc82e6208a..0c6ccf4ffbb 100644 --- a/bower.json +++ b/bower.json @@ -13,6 +13,7 @@ "dependencies": { "ace": "ace-builds#1.1.8", "d3": "d3#3.1.5", + "fontawesome": "#4.3.0", "jquery": "jquery#1.10.1", "jquery-ui": "jquery-ui#1.10.2", "mailcheck": "mailcheck#1.1.0", @@ -21,6 +22,7 @@ "normalize-css": "normalize-css#3.0.2", "nunjucks": "nunjucks#1.0.5", "nwmatcher": "nwmatcher#1.3.4", + "react": "#0.13.0", "selectivizr": "selectivizr#1.0.2", "underscore": "underscore#1.2.1" } diff --git a/kitsune/bundles.py b/kitsune/bundles.py index 4b484797d81..a800a931ebd 100644 --- a/kitsune/bundles.py +++ b/kitsune/bundles.py @@ -17,6 +17,14 @@ ), 'output_filename': 'community-min.css' }, + 'community-new': { + 'source_filenames': ( + 'bower/fontawesome/css/font-awesome.css', + 'less/wiki-content.less', + 'less/community-new.less', + ), + 'output_filename': 'community-new-min.css' + }, 'mobile-common': { 'source_filenames': ( 'bower/normalize-css/normalize.css', @@ -254,7 +262,7 @@ 'js/templates/macros.js', 'js/templates/search-results-list.js', 'js/templates/search-results.js', - 'bower/nunjucks/browsers/nunjucks-slim.js', + 'bower/nunjucks/browser/nunjucks-slim.js', 'js/nunjucks.js', 'js/cached_xhr.js', 'js/search_utils.js', @@ -282,6 +290,18 @@ ), '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', + # 'bower/react/react.js', + 'js/community-new.browserify.js', + ), + 'output_filename': 'community-new-min.js' + }, 'mobile-common': { 'source_filenames': ( 'js/i18n.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..a0194a4c75b --- /dev/null +++ b/kitsune/community/static/js/TopContributors.jsx @@ -0,0 +1,325 @@ +/* jshint esnext: true */ +/* globals React:false */ + +var dataEl = document.querySelector('script[name="locale-data"]'); +const locales = JSON.parse(dataEl.innerHTML); + +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
+ + + + +
; + } +} + +class CommunityHeader extends React.Component { + render() { + return
+

Top Contributors - Support Forum

+
; + } +} + +class CommunityFilters extends React.Component { + handleChange(ev) { + // React does some goofy stuff with events, so using when + // something like _.throttle, the event has already been destroyed + // by the time the throttled handler runs. So here we do it by hand. + var value = ev.target.value; + var newFilters = {page: null}; + + if (value === '') { + newFilters[ev.target.name] = null; + } else { + newFilters[ev.target.name] = value; + } + + clearTimeout(this._timer); + this._timer = setTimeout(this.props.setFilters.bind(null, newFilters), 200); + } + + makeInput(name) { + return + } + + render() { + return
+ {this.makeInput('username')} + {this.makeInput('startdate')} + {this.makeInput('enddate')} + + +
; + } +} + +class ContributorsTable extends React.Component { + constructor(props) { + super(props); + this.state = { + selections: props.contributors.map(function() { return false; }) + } + } + + didReceiveProps() { + var {selections} = this.state; + for (var i = 0; i < this.props.contributors.length; i++) { + selections[i] = !!selections[i]; + } + this.setState({selections}); + } + + handleSelection(index, value) { + var selections = this.state.selections; + selections[index] = value; + this.setState({selections: selections}); + } + + handleSelectAll(value) { + var selections = this.state.selections; + selections = selections.map(function() { return value; }); + this.setState({selections: selections}); + } + + render() { + if (this.props.contributors.length > 0) { + return
+ + + +
+
; + } else { + return

No contributors match filters.

; + } + } +} + +class ContributorsTableHeader extends React.Component { + handleChange(e) { + e.stopPropagation(); + this.props.onSelectAll(e.target.checked); + } + + render() { + function and(a, b) { return a && b; } + var allSelected = this.props.selections.reduce(and, true); + + return ( + + + + + + Rank + Name + Answers + Solutions + Helpful Votes + Last Activity + + + + ); + } +} + +class ContributorsTableBody extends React.Component { + render() { + return ( + + {this.props.contributors.map(function(contributor, i) { + return this.props.onSelect(i, val)} + key={contributor.user.username} + {...contributor}/>; + }.bind(this))} + + ); + } +} + +class ContributorsTableRow extends React.Component { + handleChange(e) { + e.stopPropagation(); + this.props.onSelect(e.target.checked); + } + + render() { + return ( + + + + + + {this.props.rank} + + + + + + {this.props.answer_count} + + + {this.props.solution_count} + + + {this.props.helpful_vote_count} + + + + + + + + + ); + } +} + +class UserChip extends React.Component { + render() { + return ( + + + {this.props.display_name || this.props.username} + + ); + } +} + +class RelativeTime extends React.Component { + render() { + var timestamp = moment(this.props.timestamp); + if (!timestamp.isValid()) { + return Never; + } + // Limit to the present or the past, not the future. + if (!this.props.future && timestamp.isAfter(moment())) { + timestamp = moment(); + } + return ; + } +} +RelativeTime.defaultProps = { future: true }; + +class Icon extends React.Component { + render() { + return ; + } +} + +class Paginator extends React.Component { + changePage(ev) { + ev.preventDefault(); + ev.stopPropagation(); + this.props.setFilters({page: ev.target.dataset.page}); + } + + makeSelector(page, {text=null, selected=false}={}) { + return + } + + render() { + var currentPage = parseInt(this.props.filters.page); + if (isNaN(currentPage)) { + currentPage = 1; + } + var pageCount = this.props.pageCount; + var firstPage = Math.max(1, currentPage - 4); + var lastPage = Math.min(currentPage + 5, pageCount); + var pageSelectors = []; + + // Previous button + if (currentPage > 1) { + pageSelectors.push(this.makeSelector(currentPage - 1, {text: 'Previous'})); + } + + // First page button + if (firstPage >= 2) { + pageSelectors.push(this.makeSelector(1)); + } + if (firstPage >= 3) { + pageSelectors.push(
  • ); + } + + // Normal buttons + for (var i = firstPage; i <= lastPage; i++) { + pageSelectors.push(this.makeSelector(i, {selected: i === currentPage})); + } + + // Next button + if (currentPage < pageCount) { + pageSelectors.push(this.makeSelector(currentPage + 1, {text: 'Next'})); + } + + return
      {pageSelectors}
    ; + } +} + +class PaginatorSelector extends React.Component { + render() { + var page = this.props.page; + var pageFilters = _.extend({}, this.props.filters, {page: page}); + var pageUrl = k.queryParamStringFromDict(pageFilters); + var liClasses = []; + var aClasses = []; + + if (this.props.selected) { + liClasses.push('selected'); + aClasses.push('btn-page'); + } + if (this.props.text) { + var textSlug = this.props.text.toLowerCase(); + liClasses.push(textSlug); + aClasses.push('btn-page'); + aClasses.push('btn-page-' + textSlug); + } + + return ( +
  • + + {this.props.text || this.props.page} + +
  • + ); + } +} + +PaginatorSelector.defaultProps = { + page: 1, + text: null, + selected: false, +}; diff --git a/kitsune/community/static/js/community-new.browserify.js b/kitsune/community/static/js/community-new.browserify.js new file mode 100644 index 00000000000..7ae69e2613e --- /dev/null +++ b/kitsune/community/static/js/community-new.browserify.js @@ -0,0 +1,57 @@ +/* jshint esnext: true */ +/* globals React:false */ +import {CommunityResults} from './TopContributors.jsx'; + +class TopContributors { + constructor(targetEl) { + this.targetEl = targetEl; + this.filters = k.getQueryParamsAsDict() || {}; + var dataEl = document.querySelector('script[name="contributor-data"]'); + this.data = JSON.parse(dataEl.innerHTML); + } + + setFilters(newFilters) { + var allSame = true; + _.forEach(newFilters, (value, key) => { + if (this.filters[key] !== value) { + allSame = false; + } + }); + + if (allSame) { + return; + } + + _.extend(this.filters, newFilters); + var qs = k.queryParamStringFromDict(this.filters); + history.pushState(null, '', qs); + this.refresh(); + } + + refresh() { + var qs = window.location.search; + var url = '/api/2/topcontributors/questions/' + qs; + $.getJSON(url) + .done((data) => { + this.data = data; + this.render(); + }) + .fail(function(err) { + this.targetEl.textContent = 'Something went wrong! ' + JSON.stringify(err); + }); + } + + render() { + var el = ; + React.render(el, this.targetEl); + } +} + +window.onpopstate = function() { + refresh(); +} + +var controller = new TopContributors(document.querySelector('#main-content')); +controller.render(); diff --git a/kitsune/community/static/less/community-new.less b/kitsune/community/static/less/community-new.less new file mode 100644 index 00000000000..3d103f19e1f --- /dev/null +++ b/kitsune/community/static/less/community-new.less @@ -0,0 +1,49 @@ +.community-results { + min-height: 550px; + + * { + box-sizing: border-box; + } + + h1, h2 { + text-align: center; + } +} + +table.top-contributors { + width: 100%; + + thead { + tr { + th { + padding-bottom: 5px; + } + } + } + + tbody { + tr { + height: 40px; + + td { + background: #fff; + border-bottom: 2px solid #EAEFF2; + min-width: 25px; + } + } + } + + [data-column="select"] { + text-align: center; + } +} + +.user-chip { + img { + height: 25px; + margin: -9px 5px 0 0; + position: relative; + top: 7px; + width: 25px; + } +} diff --git a/kitsune/community/templates/community/top_contributors_react.html b/kitsune/community/templates/community/top_contributors_react.html new file mode 100644 index 00000000000..11bcc4a055a --- /dev/null +++ b/kitsune/community/templates/community/top_contributors_react.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% set styles = ('common', 'community-new',) %} +{% set scripts = ('community-new',) %} + +{% if area == 'questions' %} + {% set title = _('Top Contributors - Support Forum') %} +{% elif area == 'kb' %} + {% set title = _('Top Contributors - Knowledge Base') %} +{% elif area == 'l10n' %} + {% set title = _('Top Contributors - Localization') %} +{% elif area == 'army-of-awesome' %} + {% set title = _('Top Contributors - Army of Awesome') %} +{% endif %} +{% set crumbs = [(url('community.home'), _('Community Hub')), + (None, title)] %} + +{% block content %} + + +{% endblock %} diff --git a/kitsune/community/urls.py b/kitsune/community/urls.py index 2d075fed6b9..621efe96c69 100644 --- a/kitsune/community/urls.py +++ b/kitsune/community/urls.py @@ -7,4 +7,6 @@ url(r'^/search$', 'search', name='community.search'), url(r'^/top-contributors/(?P[\w-]+)$', 'top_contributors', name='community.top_contributors'), + url(r'^/top-contributors/(?P[\w-]+)/new$', 'top_contributors_new', + name='community.top_contributors_new'), ) diff --git a/kitsune/community/urls_api.py b/kitsune/community/urls_api.py new file mode 100644 index 00000000000..2b7512e7663 --- /dev/null +++ b/kitsune/community/urls_api.py @@ -0,0 +1,9 @@ +from django.conf.urls import patterns, url + +from kitsune.community import api + + +urlpatterns = patterns( + '', + url('^topcontributors/questions/$', api.TopContributorsQuestions.as_view()), +) diff --git a/kitsune/community/utils.py b/kitsune/community/utils.py index b24e21a2a37..ac9b1ad18d5 100644 --- a/kitsune/community/utils.py +++ b/kitsune/community/utils.py @@ -47,7 +47,7 @@ def top_contributors_l10n(start=None, end=None, locale=None, product=None, .facet('creator_id', filtered=True, size=BIG_NUMBER)) if locale is None: - # If there is no locale specified, exlude en-US only. The rest are + # If there is no locale specified, exclude en-US only. The rest are # l10n. query = query.filter(~F(locale=settings.WIKI_DEFAULT_LANGUAGE)) diff --git a/kitsune/community/views.py b/kitsune/community/views.py index e0744b3d011..5aeb8725a13 100644 --- a/kitsune/community/views.py +++ b/kitsune/community/views.py @@ -1,3 +1,4 @@ + import logging from datetime import datetime @@ -5,8 +6,10 @@ from django.http import Http404 from django.shortcuts import render, get_object_or_404 +from rest_framework.renderers import JSONRenderer from statsd import statsd +from kitsune.community import api from kitsune.community.utils import ( top_contributors_aoa, top_contributors_questions, top_contributors_kb, top_contributors_l10n) @@ -175,6 +178,19 @@ def top_contributors(request, area): }) +def top_contributors_new(request, area): + to_json = JSONRenderer().render + contributors = api.TopContributorsQuestions().get_data(request) + + locales = sorted((settings.LOCALES[code].english, code) + for code in QuestionLocale.objects.locales_list()) + + return render(request, 'community/top_contributors_react.html', { + 'contributors_json': to_json(contributors), + 'locales_json': to_json(locales), + }) + + def _validate_locale(locale): """Make sure the locale is enabled on SUMO.""" if locale and locale not in settings.SUMO_LANGUAGES: diff --git a/kitsune/lib/pipeline_compilers.py b/kitsune/lib/pipeline_compilers.py new file mode 100644 index 00000000000..67c14f83df8 --- /dev/null +++ b/kitsune/lib/pipeline_compilers.py @@ -0,0 +1,36 @@ +from django.conf import settings +from django.utils.encoding import smart_bytes + +from pipeline.compilers import CompilerBase +from pipeline.exceptions import CompilerError + + +class BrowserifyCompiler(CompilerBase): + output_extension = 'browserified.js' + + def match_file(self, path): + return path.endswith('.browserify.js') + + def compile_file(self, infile, outfile, outdated=False, force=False): + command = "%s %s %s > %s" % ( + getattr(settings, 'PIPELINE_BROWSERIFY_BINARY', '/usr/bin/env browserify'), + getattr(settings, 'PIPELINE_BROWSERIFY_ARGUMENTS', ''), + infile, + outfile + ) + return self.execute_command(command) + + def execute_command(self, command, content=None, cwd=None): + """This is like the one in SubProcessCompiler, except it checks the exit code.""" + import subprocess + pipe = subprocess.Popen(command, shell=True, cwd=cwd, + stdout=subprocess.PIPE, stdin=subprocess.PIPE, + stderr=subprocess.PIPE) + if content: + content = smart_bytes(content) + stdout, stderr = pipe.communicate(content) + if self.verbose: + print(stderr) + if pipe.returncode != 0: + raise CompilerError(stderr) + return stdout diff --git a/kitsune/questions/models.py b/kitsune/questions/models.py index d046ca95366..640402a6748 100755 --- a/kitsune/questions/models.py +++ b/kitsune/questions/models.py @@ -1164,6 +1164,8 @@ def get_mapping(cls): 'is_solution': {'type': 'boolean'}, 'creator_id': {'type': 'long'}, 'by_asker': {'type': 'boolean'}, + 'helpful_count': {'type': 'integer'}, + 'unhelpful_count': {'type': 'integer'}, } } @@ -1215,6 +1217,10 @@ def extract_document(cls, obj_id, obj=None): products = Product.objects.filter(id=obj_dict['question__product_id']) d['product'] = [p.slug for p in products] + related_votes = AnswerVote.objects.filter(answer_id=obj_dict['id']) + d['helpful_count'] = related_votes.filter(helpful=True).count() + d['unhelpful_count'] = related_votes.filter(helpful=False).count() + return d diff --git a/kitsune/settings.py b/kitsune/settings.py index 7ac1b8e4eb8..ce0d6c88364 100644 --- a/kitsune/settings.py +++ b/kitsune/settings.py @@ -648,6 +648,7 @@ def JINJA_CONFIG(): # Django Pipline PIPELINE_COMPILERS = ( 'pipeline.compilers.less.LessCompiler', + 'kitsune.lib.pipeline_compilers.BrowserifyCompiler', ) PIPELINE_DISABLE_WRAPPER = True @@ -661,6 +662,11 @@ def JINJA_CONFIG(): PIPELINE_LESS_BINARY = 'lessc' +PIPELINE_BROWSERIFY_BINARY = 'browserify' +PIPELINE_BROWSERIFY_ARGUMENTS = '-t babelify -t debowerify' +if DEBUG: + PIPELINE_BROWSERIFY_ARGUMENTS += ' -d' + NUNJUCKS_PRECOMPILE_BIN = 'nunjucks-precompile' # diff --git a/kitsune/sumo/static/js/main.js b/kitsune/sumo/static/js/main.js index 67c733b746d..57bba63f5cc 100644 --- a/kitsune/sumo/static/js/main.js +++ b/kitsune/sumo/static/js/main.js @@ -28,6 +28,19 @@ window.k = window.k || {}; urlParams[d(e[1])] = d(e[2]); } return urlParams; + }; + + k.queryParamStringFromDict = function(obj) { + var qs = ''; + _.forEach(obj, function(value, key) { + if (value === undefined || value === null) { + return; + } + qs += key + '=' + encodeURIComponent(value); + qs += '&'; + }); + qs = qs.slice(0, -1); + return '?' + qs; } k.getReferrer = function(urlParams) { diff --git a/kitsune/urls.py b/kitsune/urls.py index 754d42b3277..bf9c956b4fa 100644 --- a/kitsune/urls.py +++ b/kitsune/urls.py @@ -75,6 +75,7 @@ (r'^api/2/', include('kitsune.notifications.urls_api')), (r'^api/2/', include('kitsune.questions.urls_api')), (r'^api/2/', include('kitsune.search.urls_api')), + (r'^api/2/', include('kitsune.community.urls_api')), # These API urls include both v1 and v2 urls. (r'^api/', include('kitsune.users.urls_api')), diff --git a/package.json b/package.json index 9107d7cf67f..805d93e9609 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,13 @@ "gulp-nunjucks": "0.2.2" }, "dependencies": { + "babelify": "^5.0.4", "bower": "1.3.12", + "browserify": "^9.0.3", + "cssmin": "0.4.3", + "debowerify": "^1.2.0", "less": "1.7.0", "nunjucks": "1.0.5", - "uglify-js": "2.4.13", - "cssmin": "0.4.3" + "uglify-js": "2.4.13" } } diff --git a/requirements/default.txt b/requirements/default.txt index 6b8d45d89eb..c41e9831225 100644 --- a/requirements/default.txt +++ b/requirements/default.txt @@ -164,8 +164,8 @@ https://github.com/jsocol/django-waffle/archive/a5beffb15d07b6c5f92e04dba15ab609 # sha256: NWZcEPVSRwd27M_RWSLTdI4w18AQLrd_3kfOKX2WuyM djangorestframework==2.3.14 -# sha256: fADkAZ7n55EIe3ahrkBnNW642cQlHyk3zHcOkQrvMko -elasticutils==0.10.1 +# sha256: iPK5G4pJjgyQPk5wr7H7rnTSP2L5i76WjCIVQVfL6VU +elasticutils==0.10.3 # sha256: u5PYXKflDWjkGXhSu226YJi7dyUA-C9dsv9KgHEVBck elasticsearch==1.2.0