Moved default column chart settings to settings.py. Split chart config and data into separate ajax urls to permit drawing base chart details before having data. Added a 'Loading...' notification while loading chart data. Added more test cases. Changed chart settings to eliminate empty data points from data transfer.

- Legacy-Id: 11930
This commit is contained in:
Henrik Levkowetz 2016-09-07 13:06:30 +00:00
parent 0b9ed9183a
commit f726f5c785
6 changed files with 183 additions and 120 deletions

View file

@ -1102,32 +1102,58 @@ class DocumentMeetingTests(TestCase):
class ChartTests(ResourceTestCaseMixin, TestCase):
def test_search_charts(self):
def test_search_chart_conf(self):
doc = DocumentFactory.create(states=[('draft','active')])
data_url = urlreverse("ietf.doc.views_stats.chart_data_newrevisiondocevent")
conf_url = urlreverse('ietf.doc.views_stats.chart_conf_newrevisiondocevent')
# No qurey arguments; expect an empty json object
r = self.client.get(data_url)
r = self.client.get(conf_url)
self.assertValidJSONResponse(r)
self.assertEqual(r.content, "{}")
self.assertEqual(r.content, '{}')
# No match
r = self.client.get(data_url + "?activedrafts=on&name=thisisnotadocumentname")
r = self.client.get(conf_url + '?activedrafts=on&name=thisisnotadocumentname')
self.assertValidJSONResponse(r)
d = json.loads(r.content)
self.assertEqual(r.content, "{}")
self.assertEqual(d['chart']['type'], settings.CHART_TYPE_COLUMN_OPTIONS['chart']['type'])
r = self.client.get(data_url + "?activedrafts=on&name=%s"%doc.name[6:12])
r = self.client.get(conf_url + '?activedrafts=on&name=%s'%doc.name[6:12])
self.assertValidJSONResponse(r)
d = json.loads(r.content)
self.assertEqual(len(d['series'][0]['data']), 1)
self.assertEqual(d['chart']['type'], settings.CHART_TYPE_COLUMN_OPTIONS['chart']['type'])
self.assertEqual(len(d['series'][0]['data']), 0)
chart_url = urlreverse("ietf.doc.views_stats.chart_newrevisiondocevent")
def test_search_chart_data(self):
doc = DocumentFactory.create(states=[('draft','active')])
data_url = urlreverse('ietf.doc.views_stats.chart_data_newrevisiondocevent')
# No qurey arguments; expect an empty json list
r = self.client.get(data_url)
self.assertValidJSONResponse(r)
self.assertEqual(r.content, '[]')
# No match
r = self.client.get(data_url + '?activedrafts=on&name=thisisnotadocumentname')
self.assertValidJSONResponse(r)
d = json.loads(r.content)
self.assertEqual(r.content, '[]')
r = self.client.get(data_url + '?activedrafts=on&name=%s'%doc.name[6:12])
self.assertValidJSONResponse(r)
d = json.loads(r.content)
self.assertEqual(len(d), 1)
self.assertEqual(len(d[0]), 2)
def test_search_chart(self):
doc = DocumentFactory.create(states=[('draft','active')])
chart_url = urlreverse('ietf.doc.views_stats.chart_newrevisiondocevent')
r = self.client.get(chart_url)
self.assertEqual(r.status_code, 200)
r = self.client.get(chart_url + "?activedrafts=on&name=%s"%doc.name[6:12])
r = self.client.get(chart_url + '?activedrafts=on&name=%s'%doc.name[6:12])
self.assertEqual(r.status_code, 200)
def test_personal_chart(self):
@ -1137,12 +1163,23 @@ class ChartTests(ResourceTestCaseMixin, TestCase):
authors=[person.email(), ],
)
data_url = urlreverse("ietf.doc.views_stats.chart_data_person_drafts", kwargs=dict(id=person.id))
conf_url = urlreverse('ietf.doc.views_stats.chart_conf_person_drafts', kwargs=dict(id=person.id))
r = self.client.get(conf_url)
self.assertValidJSONResponse(r)
d = json.loads(r.content)
self.assertEqual(d['chart']['type'], settings.CHART_TYPE_COLUMN_OPTIONS['chart']['type'])
self.assertEqual("New draft revisions over time for %s" % person.name, d['title']['text'])
data_url = urlreverse('ietf.doc.views_stats.chart_data_person_drafts', kwargs=dict(id=person.id))
r = self.client.get(data_url)
self.assertValidJSONResponse(r)
d = json.loads(r.content)
self.assertEqual(len(d['series'][0]['data']), 1)
self.assertEqual(len(d), 1)
self.assertEqual(len(d[0]), 2)
page_url = urlreverse('ietf.person.views.profile', kwargs=dict(email_or_name=person.name))
r = self.client.get(page_url)
self.assertEqual(r.status_code, 200)

View file

@ -54,8 +54,10 @@ urlpatterns = patterns('',
url(r'^iesg/(?P<last_call_only>[A-Za-z0-9.-]+/)?$', views_search.drafts_in_iesg_process, name="drafts_in_iesg_process"),
url(r'^email-aliases/$', views_doc.email_aliases),
url(r'^stats/newrevisiondocevent/?$', views_stats.chart_newrevisiondocevent),
url(r'^stats/data/newrevisiondocevent/?$', views_stats.chart_data_newrevisiondocevent),
url(r'^stats/data/person/(?P<id>[0-9]+)/drafts/?$', views_stats.chart_data_person_drafts),
url(r'^stats/newrevisiondocevent/conf/?$', views_stats.chart_conf_newrevisiondocevent),
url(r'^stats/newrevisiondocevent/data/?$', views_stats.chart_data_newrevisiondocevent),
url(r'^stats/person/(?P<id>[0-9]+)/drafts/conf/?$', views_stats.chart_conf_person_drafts),
url(r'^stats/person/(?P<id>[0-9]+)/drafts/data/?$', views_stats.chart_data_person_drafts),
url(r'^all/$', views_search.index_all_drafts, name="index_all_drafts"),
url(r'^active/$', views_search.index_active_drafts, name="index_active_drafts"),

View file

@ -1,5 +1,9 @@
# Copyright The IETF Trust 2016, All Rights Reserved
import copy
import datetime
from django.conf import settings
from django.core.cache import cache
from django.core.urlresolvers import reverse as urlreverse
from django.db.models.aggregates import Count
@ -19,8 +23,41 @@ from ietf.person.models import Person
epochday = datetime.datetime.utcfromtimestamp(0).date().toordinal()
def ms(t):
return (t.toordinal() - epochday)*1000*60*60*24
column_chart_conf = settings.CHART_TYPE_COLUMN_OPTIONS
def dt(s):
"Convert the date string returned by sqlite's date() to a datetime.date"
ys, ms, ds = s.split('-')
return datetime.date(int(ys), int(ms), int(ds))
def model_to_timeline_data(model, field='time', **kwargs):
"""Takes a Django model and a set of queryset filter arguments, and
returns a dictionary with highchart settings and data, suitable as
a JsonResponse() argument. The model must have a DateTimeField field.
If the time field is named something else than 'time', the name must
be supplied."""
assert field in model._meta.get_all_field_names()
objects = ( model.objects.filter(**kwargs)
.order_by('date')
.extra(select={'date': 'date(%s.%s)'% (model._meta.db_table, field) })
.values('date')
.annotate(count=Count('id')))
if objects.exists():
obj_list = list(objects)
# This is needed for sqlite, when we're running tests:
if type(obj_list[0]['date']) != datetime.date:
obj_list = [ {'date': dt(e['date']), 'count': e['count']} for e in obj_list ]
today = datetime.date.today()
if not obj_list[-1]['date'] == today:
obj_list += [ {'date': today, 'count': 0} ]
data = [ ((e['date'].toordinal()-epochday)*1000*60*60*24, e['count']) for e in obj_list ]
else:
data = []
return data
def get_doctypes(queryargs, pluralize=False):
doctypes = []
@ -47,7 +84,7 @@ def make_title(queryargs):
# radio choices
by = queryargs.get('by')
if by == "author":
title += ' with author "%s"' % queryargs['author'].title()
title += ' with author %s' % queryargs['author'].title()
elif by == "group":
group = queryargs['group']
if group:
@ -76,127 +113,64 @@ def make_title(queryargs):
def chart_newrevisiondocevent(request):
return render_to_response("doc/stats/highstock.html", {
"title": "Document Statistics",
"confurl": urlreverse("ietf.doc.views_stats.chart_conf_newrevisiondocevent"),
"dataurl": urlreverse("ietf.doc.views_stats.chart_data_newrevisiondocevent"),
"queryargs": request.GET.urlencode(),
},
context_instance=RequestContext(request))
def dt(s):
"convert the string from sqlite's date() to a datetime.date"
ys, ms, ds = s.split('-')
return datetime.date(int(ys), int(ms), int(ds))
def model_to_timeline(model, **kwargs):
"""Takes a Django model and a set of queryset filter arguments, and
returns a dictionary with highchart settings and data, suitable as
a JsonResponse() argument. The model must have a time field."""
#debug.pprint('model._meta.fields')
assert 'time' in model._meta.get_all_field_names()
objects = ( model.objects.filter(**kwargs)
.order_by('date')
.extra(select={'date': 'date(doc_docevent.time)'})
.values('date')
.annotate(count=Count('id')))
if objects.exists():
# debug.lap('got event query')
obj_list = list(objects)
# debug.lap('got event list')
# This is needed for sqlite, when we're running tests:
if type(obj_list[0]['date']) != datetime.date:
# debug.say('converting string dates to datetime.date')
obj_list = [ {'date': dt(e['date']), 'count': e['count']} for e in obj_list ]
points = [ ((e['date'].toordinal()-epochday)*1000*60*60*24, e['count']) for e in obj_list ]
# debug.lap('got event points')
counts = dict(points)
# debug.lap('got points dictionary')
day_ms = 1000*60*60*24
days = range(points[0][0], points[-1][0]+day_ms, day_ms)
# debug.lap('got days array')
data = [ (d, counts[d] if d in counts else 0) for d in days ]
# debug.lap('merged points into days')
else:
data = []
info = {
"chart": {
"type": 'column'
},
"rangeSelector" : {
"selected": 4,
"allButtonsEnabled": True,
},
"title" : {
"text" : "%s items over time" % model._meta.model_name
},
"credits": {
"enabled": False,
},
"series" : [{
"name" : "Items",
"type" : "column",
"data" : data,
"dataGrouping": {
"units": [[
'week', # unit name
[1,], # allowed multiples
], [
'month',
[1, 4,],
]]
},
"turboThreshold": 1, # Only check format of first data point. All others are the same
"pointInterval": 24*60*60*1000,
"pointPadding": 0.05,
}]
}
return info
@cache_page(60*15)
#@cache_page(60*15)
def chart_data_newrevisiondocevent(request):
# debug.mark()
queryargs = request.GET
if queryargs:
# debug.lap('got queryargs')
key = get_search_cache_key(queryargs)
# debug.lap('got cache key')
results = cache.get(key)
# debug.lap('did cache lookup')
if not results:
# debug.say('doing new search')
form = SearchForm(queryargs)
# debug.lap('set up search form')
if not form.is_valid():
return HttpResponseBadRequest("form not valid: %s" % form.errors)
results = retrieve_search_results(form)
# debug.lap('got search result')
if results.exists():
cache.set(key, results)
# debug.lap('cached search result')
if results.exists():
info = model_to_timeline(DocEvent, doc__in=results, type='new_revision')
info['title']['text'] = make_title(queryargs)
info['series'][0]['name'] = "Submitted %s" % get_doctypes(queryargs, pluralize=True).lower(),
data = model_to_timeline_data(DocEvent, doc__in=results, type='new_revision')
else:
info = {}
# debug.clock('set up info dict')
data = []
else:
info = {}
return JsonResponse(info)
data = []
return JsonResponse(data, safe=False)
@cache_page(60*15)
def chart_data_person_drafts(request, id):
# debug.mark()
def chart_conf_newrevisiondocevent(request):
queryargs = request.GET
if queryargs:
conf = copy.deepcopy(settings.CHART_TYPE_COLUMN_OPTIONS)
conf['title']['text'] = make_title(queryargs)
conf['series'][0]['name'] = "Submitted %s" % get_doctypes(queryargs, pluralize=True).lower(),
else:
conf = {}
return JsonResponse(conf)
@cache_page(60*15)
def chart_conf_person_drafts(request, id):
person = Person.objects.filter(id=id).first()
if not person:
info = {}
conf = {}
else:
info = model_to_timeline(DocEvent, doc__authors__person=person, type='new_revision')
info['title']['text'] = "New draft revisions over time for %s" % person.name
info['series'][0]['name'] = "Submitted drafts"
return JsonResponse(info)
conf = copy.deepcopy(settings.CHART_TYPE_COLUMN_OPTIONS)
conf['title']['text'] = "New draft revisions over time for %s" % person.name
conf['series'][0]['name'] = "Submitted drafts"
return JsonResponse(conf)
@cache_page(60*15)
def chart_data_person_drafts(request, id):
person = Person.objects.filter(id=id).first()
if not person:
data = []
else:
data = model_to_timeline_data(DocEvent, doc__authors__person=person, type='new_revision')
return JsonResponse(data, safe=False)

View file

@ -693,6 +693,45 @@ MAILMAN_LIB_DIR = '/usr/lib/mailman'
LIST_ACCOUNT_DELAY = 60*60*25 # 25 hours
ACCOUNT_REQUEST_EMAIL = 'account-request@ietf.org'
CHART_TYPE_COLUMN_OPTIONS = {
"chart": {
"type": 'column',
},
"credits": {
"enabled": False,
},
"rangeSelector" : {
"selected": 5,
"allButtonsEnabled": True,
},
"series" : [{
"name" : "Items",
"type" : "column",
"data" : [],
"dataGrouping": {
"units": [[
'week', # unit name
[1,], # allowed multiples
], [
'month',
[1, 4,],
]]
},
"turboThreshold": 1, # Only check format of first data point. All others are the same
"pointIntervalUnit": 'day',
"pointPadding": 0.05,
}],
"title" : {
"text" : "Items over time"
},
"xAxis": {
"type": "datetime",
# This makes the axis use the given coordinates, rather than
# squashing them to equidistant columns
"ordinal": False,
},
}
# Put the production SECRET_KEY in settings_local.py, and also any other
# sensitive or site-specific changes. DO NOT commit settings_local.py to svn.

View file

@ -9,9 +9,14 @@
<script src="{% static 'highcharts/modules/exporting.js' %}"></script>
<script>
$(function () {
$.getJSON('{{ dataurl }}?{{ queryargs }}', function (info) {
// Create the chart
$('#chart').highcharts('StockChart', info);
var chart;
$.getJSON('{{ confurl }}?{{ queryargs }}', function (conf) {
chart = Highcharts.stockChart('chart', conf);
chart.showLoading();
$.getJSON('{{ dataurl }}?{{ queryargs }}', function (data) {
chart.series[0].setData(data);
chart.hideLoading();
});
});
});
</script>

View file

@ -82,11 +82,17 @@
<script src="{% static 'highcharts/modules/exporting.js' %}"></script>
<script>
$(function () {
$.getJSON('{% url "ietf.doc.views_stats.chart_data_person_drafts" id=persons.0.id %}', function (info) {
$.getJSON('{% url "ietf.doc.views_stats.chart_conf_person_drafts" id=persons.0.id %}', function (conf) {
// Create the chart
$('#chart').highcharts('StockChart', info);
chart = Highcharts.stockChart('chart', conf);
chart.showLoading();
$.getJSON('{% url "ietf.doc.views_stats.chart_data_person_drafts" id=persons.0.id %}', function (data) {
chart.series[0].setData(data);
chart.hideLoading();
});
});
});
</script>
{% endif %}
{% endblock %}