Skip to content

Commit f726f5c

Browse files
committed
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
1 parent 0b9ed91 commit f726f5c

6 files changed

Lines changed: 181 additions & 118 deletions

File tree

ietf/doc/tests.py

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,32 +1102,58 @@ def test_add_document_session(self):
11021102

11031103

11041104
class ChartTests(ResourceTestCaseMixin, TestCase):
1105-
def test_search_charts(self):
1105+
def test_search_chart_conf(self):
11061106
doc = DocumentFactory.create(states=[('draft','active')])
11071107

1108-
data_url = urlreverse("ietf.doc.views_stats.chart_data_newrevisiondocevent")
1108+
conf_url = urlreverse('ietf.doc.views_stats.chart_conf_newrevisiondocevent')
11091109

11101110
# No qurey arguments; expect an empty json object
1111+
r = self.client.get(conf_url)
1112+
self.assertValidJSONResponse(r)
1113+
self.assertEqual(r.content, '{}')
1114+
1115+
# No match
1116+
r = self.client.get(conf_url + '?activedrafts=on&name=thisisnotadocumentname')
1117+
self.assertValidJSONResponse(r)
1118+
d = json.loads(r.content)
1119+
self.assertEqual(d['chart']['type'], settings.CHART_TYPE_COLUMN_OPTIONS['chart']['type'])
1120+
1121+
r = self.client.get(conf_url + '?activedrafts=on&name=%s'%doc.name[6:12])
1122+
self.assertValidJSONResponse(r)
1123+
d = json.loads(r.content)
1124+
self.assertEqual(d['chart']['type'], settings.CHART_TYPE_COLUMN_OPTIONS['chart']['type'])
1125+
self.assertEqual(len(d['series'][0]['data']), 0)
1126+
1127+
def test_search_chart_data(self):
1128+
doc = DocumentFactory.create(states=[('draft','active')])
1129+
1130+
data_url = urlreverse('ietf.doc.views_stats.chart_data_newrevisiondocevent')
1131+
1132+
# No qurey arguments; expect an empty json list
11111133
r = self.client.get(data_url)
11121134
self.assertValidJSONResponse(r)
1113-
self.assertEqual(r.content, "{}")
1135+
self.assertEqual(r.content, '[]')
11141136

11151137
# No match
1116-
r = self.client.get(data_url + "?activedrafts=on&name=thisisnotadocumentname")
1138+
r = self.client.get(data_url + '?activedrafts=on&name=thisisnotadocumentname')
11171139
self.assertValidJSONResponse(r)
11181140
d = json.loads(r.content)
1119-
self.assertEqual(r.content, "{}")
1141+
self.assertEqual(r.content, '[]')
11201142

1121-
r = self.client.get(data_url + "?activedrafts=on&name=%s"%doc.name[6:12])
1143+
r = self.client.get(data_url + '?activedrafts=on&name=%s'%doc.name[6:12])
11221144
self.assertValidJSONResponse(r)
11231145
d = json.loads(r.content)
1124-
self.assertEqual(len(d['series'][0]['data']), 1)
1146+
self.assertEqual(len(d), 1)
1147+
self.assertEqual(len(d[0]), 2)
1148+
1149+
def test_search_chart(self):
1150+
doc = DocumentFactory.create(states=[('draft','active')])
11251151

1126-
chart_url = urlreverse("ietf.doc.views_stats.chart_newrevisiondocevent")
1152+
chart_url = urlreverse('ietf.doc.views_stats.chart_newrevisiondocevent')
11271153
r = self.client.get(chart_url)
11281154
self.assertEqual(r.status_code, 200)
11291155

1130-
r = self.client.get(chart_url + "?activedrafts=on&name=%s"%doc.name[6:12])
1156+
r = self.client.get(chart_url + '?activedrafts=on&name=%s'%doc.name[6:12])
11311157
self.assertEqual(r.status_code, 200)
11321158

11331159
def test_personal_chart(self):
@@ -1137,12 +1163,23 @@ def test_personal_chart(self):
11371163
authors=[person.email(), ],
11381164
)
11391165

1140-
data_url = urlreverse("ietf.doc.views_stats.chart_data_person_drafts", kwargs=dict(id=person.id))
1166+
conf_url = urlreverse('ietf.doc.views_stats.chart_conf_person_drafts', kwargs=dict(id=person.id))
1167+
1168+
r = self.client.get(conf_url)
1169+
self.assertValidJSONResponse(r)
1170+
d = json.loads(r.content)
1171+
self.assertEqual(d['chart']['type'], settings.CHART_TYPE_COLUMN_OPTIONS['chart']['type'])
1172+
self.assertEqual("New draft revisions over time for %s" % person.name, d['title']['text'])
1173+
1174+
data_url = urlreverse('ietf.doc.views_stats.chart_data_person_drafts', kwargs=dict(id=person.id))
11411175

11421176
r = self.client.get(data_url)
11431177
self.assertValidJSONResponse(r)
11441178
d = json.loads(r.content)
1145-
self.assertEqual(len(d['series'][0]['data']), 1)
1146-
1179+
self.assertEqual(len(d), 1)
1180+
self.assertEqual(len(d[0]), 2)
1181+
1182+
page_url = urlreverse('ietf.person.views.profile', kwargs=dict(email_or_name=person.name))
1183+
r = self.client.get(page_url)
1184+
self.assertEqual(r.status_code, 200)
11471185

1148-

ietf/doc/urls.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,10 @@
5454
url(r'^iesg/(?P<last_call_only>[A-Za-z0-9.-]+/)?$', views_search.drafts_in_iesg_process, name="drafts_in_iesg_process"),
5555
url(r'^email-aliases/$', views_doc.email_aliases),
5656
url(r'^stats/newrevisiondocevent/?$', views_stats.chart_newrevisiondocevent),
57-
url(r'^stats/data/newrevisiondocevent/?$', views_stats.chart_data_newrevisiondocevent),
58-
url(r'^stats/data/person/(?P<id>[0-9]+)/drafts/?$', views_stats.chart_data_person_drafts),
57+
url(r'^stats/newrevisiondocevent/conf/?$', views_stats.chart_conf_newrevisiondocevent),
58+
url(r'^stats/newrevisiondocevent/data/?$', views_stats.chart_data_newrevisiondocevent),
59+
url(r'^stats/person/(?P<id>[0-9]+)/drafts/conf/?$', views_stats.chart_conf_person_drafts),
60+
url(r'^stats/person/(?P<id>[0-9]+)/drafts/data/?$', views_stats.chart_data_person_drafts),
5961

6062
url(r'^all/$', views_search.index_all_drafts, name="index_all_drafts"),
6163
url(r'^active/$', views_search.index_active_drafts, name="index_active_drafts"),

ietf/doc/views_stats.py

Lines changed: 72 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
# Copyright The IETF Trust 2016, All Rights Reserved
2+
3+
import copy
14
import datetime
25

6+
from django.conf import settings
37
from django.core.cache import cache
48
from django.core.urlresolvers import reverse as urlreverse
59
from django.db.models.aggregates import Count
@@ -19,8 +23,41 @@
1923

2024
epochday = datetime.datetime.utcfromtimestamp(0).date().toordinal()
2125

22-
def ms(t):
23-
return (t.toordinal() - epochday)*1000*60*60*24
26+
column_chart_conf = settings.CHART_TYPE_COLUMN_OPTIONS
27+
28+
def dt(s):
29+
"Convert the date string returned by sqlite's date() to a datetime.date"
30+
ys, ms, ds = s.split('-')
31+
return datetime.date(int(ys), int(ms), int(ds))
32+
33+
def model_to_timeline_data(model, field='time', **kwargs):
34+
"""Takes a Django model and a set of queryset filter arguments, and
35+
returns a dictionary with highchart settings and data, suitable as
36+
a JsonResponse() argument. The model must have a DateTimeField field.
37+
If the time field is named something else than 'time', the name must
38+
be supplied."""
39+
assert field in model._meta.get_all_field_names()
40+
41+
objects = ( model.objects.filter(**kwargs)
42+
.order_by('date')
43+
.extra(select={'date': 'date(%s.%s)'% (model._meta.db_table, field) })
44+
.values('date')
45+
.annotate(count=Count('id')))
46+
if objects.exists():
47+
obj_list = list(objects)
48+
# This is needed for sqlite, when we're running tests:
49+
if type(obj_list[0]['date']) != datetime.date:
50+
obj_list = [ {'date': dt(e['date']), 'count': e['count']} for e in obj_list ]
51+
today = datetime.date.today()
52+
if not obj_list[-1]['date'] == today:
53+
obj_list += [ {'date': today, 'count': 0} ]
54+
data = [ ((e['date'].toordinal()-epochday)*1000*60*60*24, e['count']) for e in obj_list ]
55+
else:
56+
data = []
57+
58+
return data
59+
60+
2461

2562
def get_doctypes(queryargs, pluralize=False):
2663
doctypes = []
@@ -47,7 +84,7 @@ def make_title(queryargs):
4784
# radio choices
4885
by = queryargs.get('by')
4986
if by == "author":
50-
title += ' with author "%s"' % queryargs['author'].title()
87+
title += ' with author %s' % queryargs['author'].title()
5188
elif by == "group":
5289
group = queryargs['group']
5390
if group:
@@ -76,127 +113,64 @@ def make_title(queryargs):
76113
def chart_newrevisiondocevent(request):
77114
return render_to_response("doc/stats/highstock.html", {
78115
"title": "Document Statistics",
116+
"confurl": urlreverse("ietf.doc.views_stats.chart_conf_newrevisiondocevent"),
79117
"dataurl": urlreverse("ietf.doc.views_stats.chart_data_newrevisiondocevent"),
80118
"queryargs": request.GET.urlencode(),
81119
},
82120
context_instance=RequestContext(request))
83121

84-
def dt(s):
85-
"convert the string from sqlite's date() to a datetime.date"
86-
ys, ms, ds = s.split('-')
87-
return datetime.date(int(ys), int(ms), int(ds))
88-
89-
def model_to_timeline(model, **kwargs):
90-
"""Takes a Django model and a set of queryset filter arguments, and
91-
returns a dictionary with highchart settings and data, suitable as
92-
a JsonResponse() argument. The model must have a time field."""
93-
#debug.pprint('model._meta.fields')
94-
assert 'time' in model._meta.get_all_field_names()
95-
96-
objects = ( model.objects.filter(**kwargs)
97-
.order_by('date')
98-
.extra(select={'date': 'date(doc_docevent.time)'})
99-
.values('date')
100-
.annotate(count=Count('id')))
101-
if objects.exists():
102-
# debug.lap('got event query')
103-
obj_list = list(objects)
104-
# debug.lap('got event list')
105-
# This is needed for sqlite, when we're running tests:
106-
if type(obj_list[0]['date']) != datetime.date:
107-
# debug.say('converting string dates to datetime.date')
108-
obj_list = [ {'date': dt(e['date']), 'count': e['count']} for e in obj_list ]
109-
points = [ ((e['date'].toordinal()-epochday)*1000*60*60*24, e['count']) for e in obj_list ]
110-
# debug.lap('got event points')
111-
counts = dict(points)
112-
# debug.lap('got points dictionary')
113-
day_ms = 1000*60*60*24
114-
days = range(points[0][0], points[-1][0]+day_ms, day_ms)
115-
# debug.lap('got days array')
116-
data = [ (d, counts[d] if d in counts else 0) for d in days ]
117-
# debug.lap('merged points into days')
118-
else:
119-
data = []
120-
121-
info = {
122-
"chart": {
123-
"type": 'column'
124-
},
125-
"rangeSelector" : {
126-
"selected": 4,
127-
"allButtonsEnabled": True,
128-
},
129-
"title" : {
130-
"text" : "%s items over time" % model._meta.model_name
131-
},
132-
"credits": {
133-
"enabled": False,
134-
},
135-
"series" : [{
136-
"name" : "Items",
137-
"type" : "column",
138-
"data" : data,
139-
"dataGrouping": {
140-
"units": [[
141-
'week', # unit name
142-
[1,], # allowed multiples
143-
], [
144-
'month',
145-
[1, 4,],
146-
]]
147-
},
148-
"turboThreshold": 1, # Only check format of first data point. All others are the same
149-
"pointInterval": 24*60*60*1000,
150-
"pointPadding": 0.05,
151-
}]
152-
}
153-
return info
154-
155-
156-
157-
@cache_page(60*15)
122+
#@cache_page(60*15)
158123
def chart_data_newrevisiondocevent(request):
159-
# debug.mark()
160124
queryargs = request.GET
161125
if queryargs:
162-
# debug.lap('got queryargs')
163126
key = get_search_cache_key(queryargs)
164-
# debug.lap('got cache key')
165127
results = cache.get(key)
166-
# debug.lap('did cache lookup')
167128
if not results:
168-
# debug.say('doing new search')
169129
form = SearchForm(queryargs)
170-
# debug.lap('set up search form')
171130
if not form.is_valid():
172131
return HttpResponseBadRequest("form not valid: %s" % form.errors)
173132
results = retrieve_search_results(form)
174-
# debug.lap('got search result')
175133
if results.exists():
176134
cache.set(key, results)
177-
# debug.lap('cached search result')
178135
if results.exists():
179-
info = model_to_timeline(DocEvent, doc__in=results, type='new_revision')
180-
info['title']['text'] = make_title(queryargs)
181-
info['series'][0]['name'] = "Submitted %s" % get_doctypes(queryargs, pluralize=True).lower(),
136+
data = model_to_timeline_data(DocEvent, doc__in=results, type='new_revision')
182137
else:
183-
info = {}
184-
# debug.clock('set up info dict')
138+
data = []
185139
else:
186-
info = {}
187-
return JsonResponse(info)
140+
data = []
141+
return JsonResponse(data, safe=False)
188142

189143

144+
@cache_page(60*15)
145+
def chart_conf_newrevisiondocevent(request):
146+
queryargs = request.GET
147+
if queryargs:
148+
conf = copy.deepcopy(settings.CHART_TYPE_COLUMN_OPTIONS)
149+
conf['title']['text'] = make_title(queryargs)
150+
conf['series'][0]['name'] = "Submitted %s" % get_doctypes(queryargs, pluralize=True).lower(),
151+
else:
152+
conf = {}
153+
return JsonResponse(conf)
154+
155+
156+
@cache_page(60*15)
157+
def chart_conf_person_drafts(request, id):
158+
person = Person.objects.filter(id=id).first()
159+
if not person:
160+
conf = {}
161+
else:
162+
conf = copy.deepcopy(settings.CHART_TYPE_COLUMN_OPTIONS)
163+
conf['title']['text'] = "New draft revisions over time for %s" % person.name
164+
conf['series'][0]['name'] = "Submitted drafts"
165+
return JsonResponse(conf)
166+
190167
@cache_page(60*15)
191168
def chart_data_person_drafts(request, id):
192-
# debug.mark()
193169
person = Person.objects.filter(id=id).first()
194170
if not person:
195-
info = {}
171+
data = []
196172
else:
197-
info = model_to_timeline(DocEvent, doc__authors__person=person, type='new_revision')
198-
info['title']['text'] = "New draft revisions over time for %s" % person.name
199-
info['series'][0]['name'] = "Submitted drafts"
200-
return JsonResponse(info)
173+
data = model_to_timeline_data(DocEvent, doc__authors__person=person, type='new_revision')
174+
return JsonResponse(data, safe=False)
201175

202176

ietf/settings.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,45 @@ def skip_unreadable_post(record):
693693
LIST_ACCOUNT_DELAY = 60*60*25 # 25 hours
694694
ACCOUNT_REQUEST_EMAIL = 'account-request@ietf.org'
695695

696+
CHART_TYPE_COLUMN_OPTIONS = {
697+
"chart": {
698+
"type": 'column',
699+
},
700+
"credits": {
701+
"enabled": False,
702+
},
703+
"rangeSelector" : {
704+
"selected": 5,
705+
"allButtonsEnabled": True,
706+
},
707+
"series" : [{
708+
"name" : "Items",
709+
"type" : "column",
710+
"data" : [],
711+
"dataGrouping": {
712+
"units": [[
713+
'week', # unit name
714+
[1,], # allowed multiples
715+
], [
716+
'month',
717+
[1, 4,],
718+
]]
719+
},
720+
"turboThreshold": 1, # Only check format of first data point. All others are the same
721+
"pointIntervalUnit": 'day',
722+
"pointPadding": 0.05,
723+
}],
724+
"title" : {
725+
"text" : "Items over time"
726+
},
727+
"xAxis": {
728+
"type": "datetime",
729+
# This makes the axis use the given coordinates, rather than
730+
# squashing them to equidistant columns
731+
"ordinal": False,
732+
},
733+
}
734+
696735

697736
# Put the production SECRET_KEY in settings_local.py, and also any other
698737
# sensitive or site-specific changes. DO NOT commit settings_local.py to svn.

ietf/templates/doc/stats/highstock.html

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,14 @@
99
<script src="{% static 'highcharts/modules/exporting.js' %}"></script>
1010
<script>
1111
$(function () {
12-
$.getJSON('{{ dataurl }}?{{ queryargs }}', function (info) {
13-
// Create the chart
14-
$('#chart').highcharts('StockChart', info);
12+
var chart;
13+
$.getJSON('{{ confurl }}?{{ queryargs }}', function (conf) {
14+
chart = Highcharts.stockChart('chart', conf);
15+
chart.showLoading();
16+
$.getJSON('{{ dataurl }}?{{ queryargs }}', function (data) {
17+
chart.series[0].setData(data);
18+
chart.hideLoading();
19+
});
1520
});
1621
});
1722
</script>

0 commit comments

Comments
 (0)