diff --git a/changelog b/changelog index 93c3b3af1..f021b6791 100644 --- a/changelog +++ b/changelog @@ -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 06 Sep 2016 06:20:52 -0700 + + ietfdb (6.31.1) ietf; urgency=low This release adds more proceedings generation functionality, adds diff --git a/ietf/doc/factories.py b/ietf/doc/factories.py index 5aaec53ec..7e59a4927 100644 --- a/ietf/doc/factories.py +++ b/ietf/doc/factories.py @@ -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) + diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index 05088a14b..c0797ef50 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -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) + + diff --git a/ietf/doc/urls.py b/ietf/doc/urls.py index f4ccc722d..d478be5d4 100644 --- a/ietf/doc/urls.py +++ b/ietf/doc/urls.py @@ -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[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"), diff --git a/ietf/doc/views_stats.py b/ietf/doc/views_stats.py index 7c4baccd7..a82281440 100644 --- a/ietf/doc/views_stats.py +++ b/ietf/doc/views_stats.py @@ -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) + + diff --git a/ietf/person/models.py b/ietf/person/models.py index 94d70a4f8..de0d49b8b 100644 --- a/ietf/person/models.py +++ b/ietf/person/models.py @@ -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 diff --git a/ietf/person/views.py b/ietf/person/views.py index 3970e334f..1234f6841 100644 --- a/ietf/person/views.py +++ b/ietf/person/views.py @@ -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()}) diff --git a/ietf/templates/doc/stats/highstock.html b/ietf/templates/doc/stats/highstock.html index ec029fc70..e49a15cf2 100644 --- a/ietf/templates/doc/stats/highstock.html +++ b/ietf/templates/doc/stats/highstock.html @@ -24,6 +24,6 @@ {% block content %} {% origin %} -
+
{% endblock %} diff --git a/ietf/templates/person/profile.html b/ietf/templates/person/profile.html index 178d9f1d5..d68a09dd0 100644 --- a/ietf/templates/person/profile.html +++ b/ietf/templates/person/profile.html @@ -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" }} + +
+

RFCs

+ {% if person.rfcs.exists %} + + {% for doc in person.rfcs %} +
  • {{ doc.canonical_name }}
  • + {% endfor %} +
    + {% else %} + {{ person.first_name }} has no RFCs as of {{ today }}. + {% endif %} +
    +
    +

    Active Drafts

    + {% if person.active_drafts.exists %} + + {% for doc in person.active_drafts %} +
  • {{ doc.canonical_name }}
  • + {% endfor %} +
    + {% else %} + {{ person.first_name }} has no active drafts as of {{ today }}. + {% endif %} +
    +
    +

    Expired Drafts

    + {% if person.expired_drafts.exists %} + + {% for doc in person.expired_drafts %} +
  • {{ doc.canonical_name }}
  • + {% endfor %} +
    + {% else %} + {{ person.first_name }} has no expired drafts as of {{ today }}. + {% endif %} +
    + + {% if person.has_drafts %} +
    + {% endif %} + {% endfor %} {% endblock %} + +{% block js %} + {% if persons|length == 1 %} + + + + {% endif %} +{% endblock %}