Added document information and submission timeline to profile pages. Refactored the chart data generation.

- Legacy-Id: 11928
This commit is contained in:
Henrik Levkowetz 2016-09-06 19:25:51 +00:00
parent 74f7c5782b
commit 03d5b07e51
9 changed files with 231 additions and 78 deletions

View file

@ -1,3 +1,25 @@
ietfdb (6.32.0) ietf; urgency=low
**Initial charting support**
This release brings in some basic charting support, and a set of initial
charts showing new-revision timelines for document search results. There
are also a few bugfixes:
* Fixed a blowup which could happen if an rfc doesn't have its standard
level set.
* Fixed a bug in the rfceditor index sync introduced by the event saving
refactoring.
* Fixed document methods .get_file_path() and .href() for historic
meeting documents, to make urls like /doc/minutes-96-detnet/1/ work.
* Fixed a bug in bin/mkrelease.
-- Henrik Levkowetz <henrik@levkowetz.com> 06 Sep 2016 06:20:52 -0700
ietfdb (6.31.1) ietf; urgency=low
This release adds more proceedings generation functionality, adds

View file

@ -1,6 +1,6 @@
import factory
from ietf.doc.models import Document, DocEvent, NewRevisionDocEvent, DocAlias, State
from ietf.doc.models import Document, DocEvent, NewRevisionDocEvent, DocAlias, State, DocumentAuthor
def draft_name_generator(type_id,group,n):
return '%s-%s-%s-%s%d'%(
@ -42,6 +42,14 @@ class DocumentFactory(factory.DjangoModelFactory):
for (state_type_id,state_slug) in extracted:
self.set_state(State.objects.get(type_id=state_type_id,slug=state_slug))
@factory.post_generation
def authors(self, create, extracted, **kwargs):
if create and extracted:
order = 0
for email in extracted:
DocumentAuthor.objects.create(document=self, author=email, order=order)
order += 1
@classmethod
def _after_postgeneration(cls, obj, create, results=None):
"""Save again the instance if creating and at least one hook ran."""
@ -81,3 +89,4 @@ class NewRevisionDocEventFactory(DocEventFactory):
@factory.lazy_attribute
def desc(self):
return 'New version available %s-%s'%(self.doc.name,self.rev)

View file

@ -1102,7 +1102,7 @@ class DocumentMeetingTests(TestCase):
class ChartTests(ResourceTestCaseMixin, TestCase):
def test_stats(self):
def test_search_charts(self):
doc = DocumentFactory.create(states=[('draft','active')])
data_url = urlreverse("ietf.doc.views_stats.chart_data_newrevisiondocevent")
@ -1116,7 +1116,7 @@ class ChartTests(ResourceTestCaseMixin, TestCase):
r = self.client.get(data_url + "?activedrafts=on&name=thisisnotadocumentname")
self.assertValidJSONResponse(r)
d = json.loads(r.content)
self.assertEqual(d['series'][0]['data'], [])
self.assertEqual(r.content, "{}")
r = self.client.get(data_url + "?activedrafts=on&name=%s"%doc.name[6:12])
self.assertValidJSONResponse(r)
@ -1130,4 +1130,19 @@ class ChartTests(ResourceTestCaseMixin, TestCase):
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):
person = PersonFactory.create()
DocumentFactory.create(
states=[('draft','active')],
authors=[person.email(), ],
)
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)

View file

@ -55,6 +55,7 @@ urlpatterns = patterns('',
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'^all/$', views_search.index_all_drafts, name="index_all_drafts"),
url(r'^active/$', views_search.index_active_drafts, name="index_active_drafts"),

View file

@ -42,25 +42,35 @@ def get_doctypes(queryargs, pluralize=False):
def make_title(queryargs):
title = 'New '
title += get_doctypes(queryargs)
title += ' Revisions'
title += get_doctypes(queryargs).lower()
title += ' revisions'
# radio choices
by = queryargs.get('by')
if by == "author":
title += ' with author "%s"' % queryargs['author'].title()
elif by == "group":
title += ' for %s' % queryargs['group'].capitalize()
group = queryargs['group']
if group:
title += ' for %s' % group.capitalize()
elif by == "area":
title += ' in %s Area' % queryargs['area'].upper()
area = queryargs['area']
if area:
title += ' in %s Area' % area.upper()
elif by == "ad":
title += ' with AD %s' % Person.objects.get(id=queryargs['ad'])
ad_id = queryargs['ad']
if ad_id:
title += ' with AD %s' % Person.objects.get(id=ad_id)
elif by == "state":
title += ' in state %s::%s' % (queryargs['state'], queryargs['substate'])
state = queryargs['state']
if state:
title += ' in state %s::%s' % (state, queryargs['substate'])
elif by == "stream":
title += ' in stream %s' % queryargs['stream']
stream = queryargs['stream']
if stream:
title += ' in stream %s' % stream
name = queryargs.get('name')
if name:
title += ' matching "%s"' % name
title += ' with name matching "%s"' % name
return title
def chart_newrevisiondocevent(request):
@ -76,12 +86,79 @@ def dt(s):
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)
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')
@ -98,73 +175,28 @@ def chart_data_newrevisiondocevent(request):
if results.exists():
cache.set(key, results)
# debug.lap('cached search result')
if results.exists():
events = ( DocEvent.objects.filter(doc__in=results, type='new_revision')
.order_by('date')
.extra(select={'date': 'date(doc_docevent.time)'})
.values('date')
.annotate(count=Count('id')))
if events.exists():
# debug.lap('got event query')
event_list = list(events)
# debug.lap('got event list')
# This is needed for sqlite, when we're running tests:
if type(event_list[0]['date']) != datetime.date:
# debug.say('converting string dates to datetime.date')
event_list = [ {'date': dt(e['date']), 'count': e['count']} for e in event_list ]
points = [ ((e['date'].toordinal()-epochday)*1000*60*60*24, e['count']) for e in event_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 = 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(),
else:
data = []
info = {
"chart": {
"type": 'column'
},
"rangeSelector" : {
"selected": 4,
"allButtonsEnabled": True,
},
"title" : {
"text" : make_title(queryargs)
},
"credits": {
"enabled": False,
},
"series" : [{
"name" : "Submitted %s"%get_doctypes(queryargs, pluralize=True),
"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,
}]
}
info = {}
# debug.clock('set up info dict')
else:
info = {}
return JsonResponse(info)
@cache_page(60*15)
def chart_data_person_drafts(request, id):
# debug.mark()
person = Person.objects.filter(id=id).first()
if not person:
info = {}
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)

View file

@ -63,6 +63,8 @@ class PersonInfo(models.Model):
return initials(self.ascii or self.name)
def last_name(self):
return name_parts(self.name)[3]
def first_name(self):
return name_parts(self.name)[1]
def role_email(self, role_name, group=None):
"""Lookup email for role for person, optionally on group which
may be an object or the group acronym."""
@ -108,6 +110,19 @@ class PersonInfo(models.Model):
_, first, _, last, _ = name_parts(self.ascii)
return u'%s-%s%s' % ( slugify(u"%s %s" % (first, last)), hasher.encode(self.id), '-th' if thumb else '' )
def has_drafts(self):
from ietf.doc.models import Document
return Document.objects.filter(authors__person=self, type='draft').exists()
def rfcs(self):
from ietf.doc.models import Document
return Document.objects.filter(authors__person=self, type='draft', states__slug='rfc').order_by('-time')
def active_drafts(self):
from ietf.doc.models import Document
return Document.objects.filter(authors__person=self, type='draft', states__slug='active').order_by('-time')
def expired_drafts(self):
from ietf.doc.models import Document
return Document.objects.filter(authors__person=self, type='draft', states__slug__in=['repl', 'expired', 'auth-rm', 'ietf-rm']).order_by('-time')
class Meta:
abstract = True

View file

@ -1,3 +1,4 @@
import datetime
from django.db.models import Q
from django.http import HttpResponse, Http404
@ -58,7 +59,7 @@ def profile(request, email_or_name):
persons = [ get_object_or_404(Email, address=email_or_name).person, ]
else:
aliases = Alias.objects.filter(name=email_or_name)
persons = set([ a.person for a in aliases ])
persons = list(set([ a.person for a in aliases ]))
if not persons:
raise Http404
return render(request, 'person/profile.html', {'persons': persons})
return render(request, 'person/profile.html', {'persons': persons, 'today':datetime.date.today()})

View file

@ -24,6 +24,6 @@
{% block content %}
{% origin %}
<div id="chart" style="width:100%; height:400px; border: solid gray 1px;"></div>
<div id="chart" style="width:100%; height:400px; " class="panel panel-default panel-body"></div>
{% endblock %}

View file

@ -2,8 +2,9 @@
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}
{% load markup_tags %}
{% load staticfiles %}
{% block title %}Profile for {{ person }}{% endblock %}
{% block title %}Profile for {{ persons.0 }}{% endblock %}
{% block content %}
{% origin %}
@ -30,5 +31,62 @@
{{ person.biography | apply_markup:"restructuredtext" }}
</div>
</div>
<div class="col-md-6">
<h2>RFCs</h2>
{% if person.rfcs.exists %}
<list>
{% for doc in person.rfcs %}
<li> <a href="{{ doc.get_absolute_url }}">{{ doc.canonical_name }}</a></li>
{% endfor %}
</list>
{% else %}
{{ person.first_name }} has no RFCs as of {{ today }}.
{% endif %}
</div>
<div class="col-md-6">
<h2>Active Drafts</h2>
{% if person.active_drafts.exists %}
<list>
{% for doc in person.active_drafts %}
<li> <a href="{{ doc.get_absolute_url }}">{{ doc.canonical_name }}</a></li>
{% endfor %}
</list>
{% else %}
{{ person.first_name }} has no active drafts as of {{ today }}.
{% endif %}
</div>
<div class="col-md-6">
<h2>Expired Drafts</h2>
{% if person.expired_drafts.exists %}
<list>
{% for doc in person.expired_drafts %}
<li> <a href="{{ doc.get_absolute_url }}">{{ doc.canonical_name }}</a></li>
{% endfor %}
</list>
{% else %}
{{ person.first_name }} has no expired drafts as of {{ today }}.
{% endif %}
</div>
{% if person.has_drafts %}
<div id="chart" style="width:100%; height:400px; margin-top:1em;" class="panel panel-default panel-body col-md-12"></div>
{% endif %}
{% endfor %}
{% endblock %}
{% block js %}
{% if persons|length == 1 %}
<script src="{% static 'highcharts/highstock.js' %}"></script>
<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) {
// Create the chart
$('#chart').highcharts('StockChart', info);
});
});
</script>
{% endif %}
{% endblock %}