From d6d1525da260dd101f7fb8da44a57b5529424c2a Mon Sep 17 00:00:00 2001
From: Lars Eggert
Date: Wed, 22 Feb 2023 10:05:06 -0800
Subject: [PATCH] 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
---
ietf/group/tests.py | 28 ++++-
ietf/group/urls.py | 1 +
ietf/group/views.py | 79 ++++++++++++++
ietf/static/js/highcharts.js | 110 +++++++++++++++++++-
ietf/templates/group/active_areas.html | 29 +++++-
ietf/templates/group/group_about.html | 18 ++++
ietf/templates/group/group_stats_modal.html | 45 ++++++++
7 files changed, 306 insertions(+), 4 deletions(-)
create mode 100644 ietf/templates/group/group_stats_modal.html
diff --git a/ietf/group/tests.py b/ietf/group/tests.py
index 532f599a3..af6ae0e35 100644
--- a/ietf/group/tests.py
+++ b/ietf/group/tests.py
@@ -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()
diff --git a/ietf/group/urls.py b/ietf/group/urls.py
index 46bde4ede..713a0b7ee 100644
--- a/ietf/group/urls.py
+++ b/ietf/group/urls.py
@@ -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(wg|rg))/$', views.edit, {'action': "charter"}),
diff --git a/ietf/group/views.py b/ietf/group/views.py
index 16e7bb55e..703c485e0 100644
--- a/ietf/group/views.py
+++ b/ietf/group/views.py
@@ -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):
diff --git a/ietf/static/js/highcharts.js b/ietf/static/js/highcharts.js
index f9b7aa615..0b99f87a5 100644
--- a/ietf/static/js/highcharts.js
+++ b/ietf/static/js/highcharts.js
@@ -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);
+ });
+ });
+}
diff --git a/ietf/templates/group/active_areas.html b/ietf/templates/group/active_areas.html
index d3fe45f45..0f4704932 100644
--- a/ietf/templates/group/active_areas.html
+++ b/ietf/templates/group/active_areas.html
@@ -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 @@
{% endfor %}
+
+ 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.
+