feat: Add group stats sunburst plots to active WG page (#5126)

* feat: Add group stats sunburst plots to active WG page

* Cleanups

* Sort/color areas consistently

* Move graphs to area page

* Move sunbursts

* Add test

* Move most sunbursts to modals

* Remove parametrization from URLs

* Fix test

* Make `only_active` a bool

* Reformat

* Reformat more
This commit is contained in:
Lars Eggert 2023-02-22 10:05:06 -08:00 committed by GitHub
parent 42096989ec
commit d6d1525da2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 306 additions and 4 deletions

View file

@ -18,7 +18,7 @@ from django.utils import timezone
import debug # pyflakes:ignore
from ietf.doc.factories import DocumentFactory, WgDraftFactory
from ietf.doc.models import DocEvent, RelatedDocument
from ietf.doc.models import DocEvent, RelatedDocument, Document
from ietf.group.models import Role, Group
from ietf.group.utils import get_group_role_emails, get_child_group_role_emails, get_group_ad_emails
from ietf.group.factories import GroupFactory, RoleFactory
@ -58,6 +58,32 @@ class StreamTests(TestCase):
self.assertTrue(Role.objects.filter(name="delegate", group__acronym=stream_acronym, email__address="ad2@ietf.org"))
class GroupStatsTests(TestCase):
def setUp(self):
super().setUp()
a = WgDraftFactory()
b = WgDraftFactory()
RelatedDocument.objects.create(
source=a, target=b.docalias.first(), relationship_id="refnorm"
)
def test_group_stats(self):
client = Client(Accept="application/json")
url = urlreverse("ietf.group.views.group_stats_data")
r = client.get(url)
self.assertTrue(r.status_code == 200, "Failed to receive group stats")
self.assertGreater(len(r.content), 0, "Group stats have no content")
try:
data = json.loads(r.content)
except Exception as e:
self.fail("JSON load failed: %s" % e)
ids = [d["id"] for d in data]
for doc in Document.objects.all():
self.assertIn(doc.name, ids)
class GroupDocDependencyTests(TestCase):
def setUp(self):
super().setUp()

View file

@ -53,6 +53,7 @@ info_detail_urls = [
group_urls = [
url(r'^$', views.active_groups),
url(r'^groupstats.json', views.group_stats_data, None, 'ietf.group.views.group_stats_data'),
url(r'^groupmenu.json', views.group_menu_data, None, 'ietf.group.views.group_menu_data'),
url(r'^chartering/$', views.chartering_groups),
url(r'^chartering/create/(?P<group_type>(wg|rg))/$', views.edit, {'action': "charter"}),

View file

@ -1354,6 +1354,85 @@ def group_menu_data(request):
return JsonResponse(groups_by_parent)
@cache_control(public=True, max_age=30 * 60)
@cache_page(30 * 60)
def group_stats_data(request, years="3", only_active=True):
when = timezone.now() - datetime.timedelta(days=int(years) * 365)
docs = (
Document.objects.filter(type="draft", stream="ietf")
.filter(
Q(docevent__newrevisiondocevent__time__gte=when)
| Q(docevent__type="published_rfc", docevent__time__gte=when)
)
.exclude(states__type="draft", states__slug="repl")
.distinct()
)
data = []
for a in Group.objects.filter(type="area"):
if only_active and not a.is_active:
continue
area_docs = docs.filter(group__parent=a).exclude(group__acronym="none")
if not area_docs:
continue
area_page_cnt = 0
area_doc_cnt = 0
for wg in Group.objects.filter(type="wg", parent=a):
if only_active and not wg.is_active:
continue
wg_docs = area_docs.filter(group=wg)
if not wg_docs:
continue
wg_page_cnt = 0
for doc in wg_docs:
# add doc data
data.append(
{
"id": doc.name,
"active": True,
"parent": wg.acronym,
"grandparent": a.acronym,
"pages": doc.pages,
"docs": 1,
}
)
wg_page_cnt += doc.pages
area_doc_cnt += len(wg_docs)
area_docs = area_docs.exclude(group=wg)
# add WG data
data.append(
{
"id": wg.acronym,
"active": wg.is_active,
"parent": a.acronym,
"grandparent": "ietf",
"pages": wg_page_cnt,
"docs": len(wg_docs),
}
)
area_page_cnt += wg_page_cnt
# add area data
data.append(
{
"id": a.acronym,
"active": a.is_active,
"parent": "ietf",
"pages": area_page_cnt,
"docs": area_doc_cnt,
}
)
data.append({"id": "ietf", "active": True})
return JsonResponse(data, safe=False)
# --- Review views -----------------------------------------------------
def get_open_review_requests_for_team(team, assignment_status=None):

View file

@ -3,11 +3,119 @@ import Highcharts from "highcharts";
import Highcharts_Exporting from "highcharts/modules/exporting";
import Highcharts_Offline_Exporting from "highcharts/modules/offline-exporting";
import Highcharts_Export_Data from "highcharts/modules/export-data";
import Highcharts_Accessibility from"highcharts/modules/accessibility";
import Highcharts_Accessibility from "highcharts/modules/accessibility";
import Highcharts_Sunburst from "highcharts/modules/sunburst";
Highcharts_Exporting(Highcharts);
Highcharts_Offline_Exporting(Highcharts);
Highcharts_Export_Data(Highcharts);
Highcharts_Accessibility(Highcharts);
Highcharts_Sunburst(Highcharts);
Highcharts.setOptions({
// use colors from https://colorbrewer2.org/#type=qualitative&scheme=Paired&n=12
colors: ['#a6cee3', '#1f78b4', '#b2df8a', '#33a02c', '#fb9a99',
'#e31a1c', '#fdbf6f', '#ff7f00', '#cab2d6', '#6a3d9a',
'#ffff99', '#b15928'
],
chart: {
height: "100%",
style: {
fontFamily: getComputedStyle(document.body)
.getPropertyValue('--bs-body-font-family')
}
},
credits: {
enabled: false
},
});
window.Highcharts = Highcharts;
window.group_stats = function (url, chart_selector) {
$.getJSON(url, function (data) {
$(chart_selector)
.each(function (i, e) {
const dataset = e.dataset.dataset;
if (!dataset) {
console.log("dataset data attribute not set");
return;
}
const area = e.dataset.area;
if (!area) {
console.log("area data attribute not set");
return;
}
const chart = Highcharts.chart(e, {
title: {
text: `${dataset == "docs" ? "Documents" : "Pages"} in ${area.toUpperCase()}`
},
series: [{
type: "sunburst",
data: [],
tooltip: {
pointFormatter: function () {
return `There ${this.value == 1 ? "is" : "are"} ${this.value} ${dataset == "docs" ? "documents" : "pages"} in ${this.name}.`;
}
},
dataLabels: {
formatter() {
return this.point.active ? this.point.name : `(${this.point.name})`;
}
},
allowDrillToNode: true,
cursor: 'pointer',
levels: [{
level: 1,
color: "transparent",
levelSize: {
value: .5
}
}, {
level: 2,
colorByPoint: true
}, {
level: 3,
colorVariation: {
key: "brightness",
to: 0.5
}
}]
}],
});
// limit data to area if set and (for now) drop docs
const slice = data.filter(d => (area == "ietf" && d.grandparent == area) || d.parent == area || d.id == area)
.map((d) => {
return {
value: d[dataset],
id: d.id,
parent: d.parent,
grandparent: d.grandparent,
active: d.active,
};
})
.sort((a, b) => {
if (a.parent != b.parent) {
if (a.parent < b.parent) {
return -1;
}
if (a.parent > b.parent) {
return 1;
}
} else if (a.parent == area) {
if (a.id < b.id) {
return 1;
}
if (a.id > b.id) {
return -1;
}
return 0;
}
return b.value - a.value;
});
chart.series[0].setData(slice);
});
});
}

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin textfilters ietf_filters%}
{% load origin textfilters ietf_filters static %}
{% block title %}Active areas{% endblock %}
{% block content %}
{% origin %}
@ -43,8 +43,24 @@
</li>
{% endfor %}
</ul>
<p>
The following diagrams show the sizes of the different areas and working groups,
based on the number of documents - and pages - a group has worked on in the last three years.
</p>
<div class="row mt-3">
<div class="col-sm chart text-center" data-area="ietf" data-dataset="docs">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div class="col-sm chart text-center" data-area="ietf" data-dataset="pages">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
{% for area in areas %}
<h2 class="mt-3" id="id-{{ area.acronym|slugify }}">
<h2 class="mt-5" id="id-{{ area.acronym|slugify }}">
{{ area.name }}
<a href="{% url 'ietf.group.views.active_groups' group_type='wg' %}#{{ area.acronym }}">({{ area.acronym|upper }})</a>
</h2>
@ -53,5 +69,14 @@
{{ area.description|urlize_ietf_docs|linkify|safe }}
</p>
{% endif %}
{% include "group/group_stats_modal.html" with group=area only %}
{% endfor %}
{% endblock %}
{% block js %}
<script src="{% static 'ietf/js/highcharts.js' %}"></script>
<script>
$(function () {
group_stats("{% url 'ietf.group.views.group_stats_data' %}", ".chart");
});
</script>
{% endblock %}

View file

@ -211,6 +211,18 @@ height: 100vh;
</tr>
{% endif %}
{% endwith %}
{% if group.type.slug == "area" %}
<tr>
<td></td>
<th scope="row">
Group statistics
</th>
<td class="edit"></td>
<td>
{% include "group/group_stats_modal.html" with group=group only %}
</td>
</tr>
{% endif %}
</tbody>
{% if group.personnel %}
<tbody class="meta border-top">
@ -393,4 +405,10 @@ height: 100vh;
{% block js %}
<script src="{% static 'ietf/js/d3.js' %}"></script>
<script src="{% static 'ietf/js/document_relations.js' %}"></script>
<script src="{% static 'ietf/js/highcharts.js' %}"></script>
<script>
$(function () {
group_stats("{% url 'ietf.group.views.group_stats_data' %}", ".chart");
});
</script>
{% endblock %}

View file

@ -0,0 +1,45 @@
<button type="button"
class="btn btn-sm btn-primary"
data-bs-toggle="modal"
data-bs-target="#stats-modal-{{ group.acronym|slugify }}">
<i class="bi bi-pie-chart-fill"></i> Show {{ group.acronym|upper }} statistics
</button>
<div class="modal fade"
id="stats-modal-{{ group.acronym|slugify }}"
tabindex="-1"
aria-labelledby="stats-modal-label-{{ group.acronym|slugify }}"
aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable modal-xl">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5"
id="stats-modal-label-{{ group.acronym|slugify }}">{{ group.acronym|upper }} statistics</h1>
<button type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-lg chart text-center"
data-area="{{ group.acronym|lower }}"
data-dataset="docs">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div class="col-lg chart text-center"
data-area="{{ group.acronym|lower }}"
data-dataset="pages">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>