Merged in /personal/lars/6.11.1.dev0@10590 from lars@netapp.com, which brings in a timeline view at the top of document pages.

- Legacy-Id: 10597
This commit is contained in:
Henrik Levkowetz 2015-12-15 19:17:36 +00:00
commit 213ae4921b
13 changed files with 373 additions and 22 deletions

19
.eslintrc.js Normal file
View file

@ -0,0 +1,19 @@
module.exports = {
rules: {
indent: [2, 4],
camelcase: 0,
"require-jsdoc": 0,
quotes: [2, "double"],
"no-multiple-empty-lines": [2, {max: 2}],
"quote-props": [2, "as-needed"],
"brace-style": [2, "1tbs", {allowSingleLine: true}]
},
env: {
browser: true,
jquery: true
},
globals: {
d3: true
},
extends: "google"
};

View file

@ -5,6 +5,7 @@
"main": [],
"dependencies": {
"bootstrap-datepicker": "1.5.0",
"d3": "3.5.10",
"font-awesome": "4.5.0",
"html5shiv": "3.7.3",
"jquery": "1.11.3",

View file

@ -65,7 +65,7 @@ urlpatterns = patterns('',
url(r'^(?P<name>[A-Za-z0-9._+-]+)/ballot/(?P<ballot_id>[0-9]+)/emailposition/$', views_ballot.send_ballot_comment, name='doc_send_ballot_comment'),
url(r'^(?P<name>[A-Za-z0-9._+-]+)/ballot/(?P<ballot_id>[0-9]+)/$', views_doc.document_ballot, name="doc_ballot"),
url(r'^(?P<name>[A-Za-z0-9._+-]+)/ballot/$', views_doc.document_ballot, name="doc_ballot"),
(r'^(?P<name>[A-Za-z0-9._+-]+)/doc.json$', views_doc.document_json),
(r'^(?P<name>[A-Za-z0-9._+-]+)/(?:(?P<rev>[0-9-]+)/)?doc.json$', views_doc.document_json),
(r'^(?P<name>[A-Za-z0-9._+-]+)/ballotpopup/(?P<ballot_id>[0-9]+)/$', views_doc.ballot_popup),
url(r'^(?P<name>[A-Za-z0-9._+-]+)/email-aliases/$', RedirectView.as_view(pattern_name='doc_email', permanent=False),name='doc_specific_email_aliases'),

View file

@ -8,6 +8,7 @@ from django.conf import settings
from django.db.models.query import EmptyQuerySet
from django.forms import ValidationError
from django.utils.html import strip_tags, escape
from django.core.urlresolvers import reverse as urlreverse
from ietf.doc.models import Document, DocHistory, State
from ietf.doc.models import DocAlias, RelatedDocument, BallotType, DocReminder
@ -27,7 +28,7 @@ def email_update_telechat(request, doc, text):
if not to:
return
text = strip_tags(text)
send_mail(request, to, None,
"Telechat update notice: %s" % doc.file_tag(),
@ -41,7 +42,7 @@ def get_state_types(doc):
if not doc:
return res
res.append(doc.type_id)
if doc.type_id == "draft":
@ -52,7 +53,7 @@ def get_state_types(doc):
res.append("draft-iana-review")
res.append("draft-iana-action")
res.append("draft-rfceditor")
return res
def get_tags_for_stream_id(stream_id):
@ -144,7 +145,7 @@ def needed_ballot_positions(doc, active_positions):
answer.append("Has enough positions to pass.")
return " ".join(answer)
def create_ballot_if_not_open(doc, by, ballot_slug, time=None):
if not doc.ballot_open(ballot_slug):
if time:
@ -359,7 +360,7 @@ def make_notify_changed_event(request, doc, by, new_notify, time=None):
def update_telechat(request, doc, by, new_telechat_date, new_returning_item=None):
from ietf.doc.models import TelechatDocEvent
on_agenda = bool(new_telechat_date)
prev = doc.latest_event(TelechatDocEvent, type="scheduled_for_telechat")
@ -378,7 +379,7 @@ def update_telechat(request, doc, by, new_telechat_date, new_returning_item=None
# auto-set returning item _ONLY_ if the caller did not provide a value
if ( new_returning_item != None
and on_agenda
and on_agenda
and prev_agenda
and new_telechat_date != prev_telechat
and prev_telechat < datetime.date.today()
@ -392,7 +393,7 @@ def update_telechat(request, doc, by, new_telechat_date, new_returning_item=None
e.doc = doc
e.returning_item = returning
e.telechat_date = new_telechat_date
if on_agenda != prev_agenda:
if on_agenda:
e.desc = "Placed on agenda for telechat - %s" % (new_telechat_date)
@ -426,7 +427,7 @@ def rebuild_reference_relations(doc,filename=None):
refs = draft.Draft(draft._gettext(filename), filename).get_refs()
except IOError as e:
return { 'errors': ["%s :%s" % (e.strerror, filename)] }
doc.relateddocument_set.filter(relationship__slug__in=['refnorm','refinfo','refold','refunk']).delete()
warnings = []
@ -449,11 +450,11 @@ def rebuild_reference_relations(doc,filename=None):
ret = {}
if errors:
ret['errors']=errors
ret['errors']=errors
if warnings:
ret['warnings']=warnings
ret['warnings']=warnings
if unfound:
ret['unfound']=list(unfound)
ret['unfound']=list(unfound)
return ret
@ -539,3 +540,41 @@ def uppercase_std_abbreviated_name(name):
return name.upper()
else:
return name
def crawl_history(doc):
# return document history data for inclusion in doc.json (used by timeline)
def ancestors(doc):
retval = []
if hasattr(doc, 'relateddocument_set'):
for rel in doc.relateddocument_set.filter(relationship__slug='replaces'):
if rel.target.document not in retval:
retval.append(rel.target.document)
retval.extend(ancestors(rel.target.document))
return retval
retval = []
history = ancestors(doc)
if history is not None:
history.append(doc)
for d in history:
for e in d.docevent_set.filter(type='new_revision'):
if hasattr(e, 'newrevisiondocevent'):
retval.append({
'name': d.name,
'rev': e.newrevisiondocevent.rev,
'published': e.time.isoformat(),
'url': urlreverse("doc_view", kwargs=dict(name=d)) + e.newrevisiondocevent.rev + "/"
})
if doc.type_id == "draft":
e = doc.latest_event(type='published_rfc')
else:
e = doc.latest_event(type='iesg_approved')
if e:
retval.append({
'name': e.doc.canonical_name(),
'rev': e.doc.canonical_name(),
'published': e.time.isoformat(),
'url': urlreverse("doc_view", kwargs=dict(name=e.doc))
})
return sorted(retval, key=lambda x: x['published'])

View file

@ -49,7 +49,7 @@ from ietf.doc.models import ( Document, DocAlias, DocHistory, DocEvent, BallotDo
from ietf.doc.utils import ( add_links_in_new_revision_events, augment_events_with_revision,
can_adopt_draft, get_chartering_type, get_document_content, get_tags_for_stream_id,
needed_ballot_positions, nice_consensus, prettify_std_name, update_telechat, has_same_ballot,
get_initial_notify, make_notify_changed_event )
get_initial_notify, make_notify_changed_event, crawl_history)
from ietf.community.models import CommunityList
from ietf.group.models import Role
from ietf.group.utils import can_manage_group_type, can_manage_materials
@ -878,7 +878,7 @@ def ballot_popup(request, name, ballot_id):
context_instance=RequestContext(request))
def document_json(request, name):
def document_json(request, name, rev=None):
doc = get_object_or_404(Document, docalias__name=name)
def extract_name(s):
@ -911,6 +911,9 @@ def document_json(request, name):
data["shepherd"] = doc.shepherd.formatted_email() if doc.shepherd else None
data["ad"] = doc.ad.role_email("ad").formatted_email() if doc.ad else None
latest_revision = doc.latest_event(NewRevisionDocEvent, type="new_revision")
data["rev_history"] = crawl_history(latest_revision.doc if latest_revision else doc)
if doc.type_id == "draft":
data["iesg_state"] = extract_name(doc.get_state("draft-iesg"))
data["rfceditor_state"] = extract_name(doc.get_state("draft-rfceditor"))
@ -922,7 +925,7 @@ def document_json(request, name):
data["consensus"] = e.consensus if e else None
data["stream"] = extract_name(doc.stream)
return HttpResponse(json.dumps(data, indent=2), content_type='text/plain')
return HttpResponse(json.dumps(data, indent=2), content_type='application/json')
class AddCommentForm(forms.Form):
comment = forms.CharField(required=True, widget=forms.Textarea)

5
ietf/externals/static/d3/d3.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -431,3 +431,32 @@ h1 small .pull-right { margin-top: 10.5px; }
*/
form.navbar-form input.form-control.input-sm { width: 141px; }
/* Styles for d3.js graphical SVG timelines */
#timeline { font-size: small; }
#timeline .axis path, #timeline .axis line {
fill: none;
stroke: black;
}
#timeline .axis.y path, #timeline .axis.y line { stroke: none; }
#timeline .axis.x text { dominant-baseline: central; }
#timeline .bar text {
fill: white;
dominant-baseline: central;
pointer-events: none;
}
/* like label-success */
#timeline .bar:nth-child(odd) rect { fill: #5CB85C; }
/* like label-primary */
#timeline .bar:nth-child(even) rect { fill: #337AB7; }
/* like label-warning */
#timeline .gradient.left { stop-color: #F0AD4E; }
#timeline .gradient.right { stop-color: white; }

View file

@ -0,0 +1,228 @@
"use strict";
var data;
var x_scale;
var bar_y;
var bar_height;
var y_label_width;
var x_axis;
var width;
function expiration_date(d) {
return new Date(d.published.getTime() + 1000 * 60 * 60 * 24 * 185);
}
function max(arr) {
return Math.max.apply(null, Object.keys(arr).map(function(e) {
return arr[e];
}));
}
function offset(d) {
if (bar_y[d.name] === undefined) {
var m = Object.keys(bar_y).length === 0 ? -bar_height : max(bar_y);
bar_y[d.name] = m + bar_height;
}
return "translate(" + x_scale(d.published) + ", " + bar_y[d.name] + ")";
}
function bar_width(d, i) {
// check for next rev of this name
for (i++; i < data.length; i++) {
if (data[i].name === d.name) { break; }
}
var w = i === data.length ? expiration_date(d) : data[i].published;
return x_scale(w) - x_scale(d.published);
}
function scale_x() {
width = $("#timeline").width();
// scale data to width of container minus y label width
x_scale = d3.time.scale().domain([
d3.min(data, function(d) { return d.published; }),
d3.max(data, function(d) { return d.published; })
]).range([y_label_width, width]);
// resort data by publication time to suppress some ticks if they are closer
// than 12px, and don't add a tick for the final pseudo entry
var tv = data
.slice(0, -1)
.sort(function(a, b) { return a.published - b.published; })
.map(function(d, i, arr) {
if (i === 0 ||
x_scale(d.published) > x_scale(arr[i - 1].published) + 12) {
return d.published;
}
}).filter(function(d) { return d !== undefined; });
x_axis = d3.svg.axis()
.scale(x_scale)
.tickValues(tv)
.tickFormat(d3.time.format("%b %Y"))
.orient("bottom");
}
function update_x_axis() {
d3.select("#timeline svg .x.axis").call(x_axis)
.selectAll("text")
.style("text-anchor", "end")
.attr("transform", "translate(-14, 2) rotate(-60)");
}
function update_timeline() {
bar_y = {};
scale_x();
var chart = d3.select("#timeline svg").attr("width", width);
// enter data (skip the last pseudo entry)
var bar = chart.selectAll("g").data(data.slice(0, -1));
bar.attr("transform", offset).select("rect").attr("width", bar_width);
update_x_axis();
}
function draw_timeline() {
bar_height = parseFloat($("body").css("line-height"));
var div = $("#timeline");
if (div.is(":empty")) {
div.append("<svg></svg>");
}
var chart = d3.select("#timeline svg").attr("width", width);
var gradient = chart.append("defs")
.append("linearGradient")
.attr("id", "gradient");
gradient.append("stop")
.attr({
class: "gradient left",
offset: 0
});
gradient.append("stop")
.attr({
class: "gradient right",
offset: 1
});
var y_labels = data
.map(function(d) { return d.name; })
.filter(function(val, i, self) { return self.indexOf(val) === i; });
// calculate the width of the widest y axis label by drawing them off-screen
// and measuring the bounding boxes
y_label_width = 10 + d3.max(y_labels, function(l) {
var lw;
chart.append("text")
.attr({
class: "y axis",
transform: "translate(0, " + -bar_height + ")"
})
.text(l)
.each(function() {
lw = this.getBBox().width;
})
.remove().remove();
return lw;
});
// update
update_timeline();
// re-order data by document name, for CSS background color alternation
var ndata = [];
y_labels.forEach(function(l) {
ndata = ndata.concat(data.filter(function(d) { return d.name === l; }));
});
data = ndata;
// enter data (skip the last pseudo entry)
var bar = chart.selectAll("g").data(data.slice(0, -1));
var g = bar.enter()
.append("g")
.attr({
class: "bar",
transform: offset
});
g.append("a")
.attr("xlink:href", function(d) { return d.url; })
.append("rect")
.attr({
height: bar_height,
width: bar_width
});
g.append("text")
.attr({
x: 3,
y: bar_height / 2
})
.text(function(d) { return d.rev; });
// since the gradient is defined inside the SVG, we need to set the CSS
// style here, so the relative URL works
$("#timeline .bar:last-child rect").css("fill", "url(#gradient)");
var y_scale = d3.scale.ordinal()
.domain(y_labels)
.rangePoints([0, max(bar_y) + bar_height]);
var y_axis = d3.svg.axis()
.scale(y_scale)
.tickValues(y_labels)
.orient("left");
chart.append("g").attr({
class: "x axis",
transform: "translate(0, " + (max(bar_y) + bar_height) + ")"
});
update_x_axis();
chart.append("g")
.attr({
class: "y axis",
transform: "translate(10, " + bar_height / 2 + ")"
})
.call(y_axis)
.selectAll("text")
.style("text-anchor", "start");
// set height of timeline
var x_label_height;
d3.select(".x.axis").each(function() {
x_label_height = this.getBBox().height;
});
chart.attr("height", max(bar_y) + bar_height + x_label_height);
}
d3.json("doc.json", function(error, json) {
if (error) { return; }
data = json.rev_history;
if (data.length) {
// make js dates out of publication dates
data.forEach(function(d) { d.published = new Date(d.published); });
// add pseudo entry when the ID will expire
data.push({
name: "",
rev: "",
published: expiration_date(data[data.length - 1])
});
draw_timeline();
}
});
$(window).on({
resize: function() {
update_timeline();
}
});

View file

@ -15,6 +15,7 @@
{{ top|safe }}
{% include "doc/revisions_list.html" %}
<div id="timeline"></div>
<table class="table table-condensed">
<thead id="message-row">
@ -210,3 +211,7 @@
{% endblock %}
{% block js %}
<script src="{% static 'd3/d3.min.js' %}"></script>
<script src="{% static 'ietf/js/document_timeline.js' %}"></script>
{% endblock %}

View file

@ -11,6 +11,7 @@
{{ top|safe }}
{% include "doc/revisions_list.html" %}
<div id="timeline"></div>
<table class="table table-condensed">
<thead id="message-row">
@ -137,6 +138,10 @@
<p></p>
{{ content|fill:"80"|safe|linebreaksbr|keep_spacing|sanitize_html|safe }}
{% endif %}
{% endblock %}
{% block js %}
<script src="{% static 'd3/d3.min.js' %}"></script>
<script src="{% static 'ietf/js/document_timeline.js' %}"></script>
{% endblock %}

View file

@ -10,7 +10,7 @@
{% endblock %}
{% block morecss %}
.inline { display: inline; }
.inline { display: inline; }
{% endblock %}
{% block title %}
@ -26,6 +26,7 @@
{{ top|safe }}
{% include "doc/revisions_list.html" %}
<div id="timeline"></div>
<table class="table table-condensed">
<thead id="message-row">
@ -34,7 +35,7 @@
<th colspan="4" class="alert-warning">The information below is for an old version of the document</th>
{% else %}
<th colspan="4"></th>
{% endif %}
{% endif %}
</tr>
</thead>
@ -492,5 +493,9 @@
</div>
</div>
{% endif %}
{% endblock %}
{% block js %}
<script src="{% static 'd3/d3.min.js' %}"></script>
<script src="{% static 'ietf/js/document_timeline.js' %}"></script>
{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}
{% load staticfiles %}
{% load ietf_filters %}
{% block title %}{{ doc.title }}{% endblock %}
@ -11,6 +11,7 @@
{{ top|safe }}
{% include "doc/revisions_list.html" %}
<div id="timeline"></div>
<table class="table table-condensed">
<thead id="message-row">
@ -125,5 +126,10 @@
<p class="download-instead"><a href="{{ other_types.0.1 }}">Download as {{ other_types.0.0.upper }}</a></p>
{% endif %}
{% endif %}
{% endblock %}
{% block js %}
<script src="{% static 'd3/d3.min.js' %}"></script>
<script src="{% static 'ietf/js/document_timeline.js' %}"></script>
{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}
{% load staticfiles %}
{% load ietf_filters %}
{% block title %}{{ doc.title }}{% endblock %}
@ -11,6 +11,7 @@
{{ top|safe }}
{% include "doc/revisions_list.html" %}
<div id="timeline"></div>
<table class="table table-condensed">
<thead id="message-row">
@ -153,3 +154,8 @@
{% endblock %}
{% block js %}
<script src="{% static 'd3/d3.min.js' %}"></script>
<script src="{% static 'ietf/js/document_timeline.js' %}"></script>
{% endblock %}