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. +

+
+
+
+ Loading... +
+
+
+
+ Loading... +
+
+
{% for area in areas %} -

+

{{ area.name }} ({{ area.acronym|upper }})

@@ -53,5 +69,14 @@ {{ area.description|urlize_ietf_docs|linkify|safe }}

{% endif %} + {% include "group/group_stats_modal.html" with group=area only %} {% endfor %} +{% endblock %} +{% block js %} + + {% endblock %} \ No newline at end of file diff --git a/ietf/templates/group/group_about.html b/ietf/templates/group/group_about.html index 261ffe31f..6b2a36e90 100644 --- a/ietf/templates/group/group_about.html +++ b/ietf/templates/group/group_about.html @@ -211,6 +211,18 @@ height: 100vh; {% endif %} {% endwith %} + {% if group.type.slug == "area" %} + + + + Group statistics + + + + {% include "group/group_stats_modal.html" with group=group only %} + + + {% endif %} {% if group.personnel %} @@ -393,4 +405,10 @@ height: 100vh; {% block js %} + + {% endblock %} \ No newline at end of file diff --git a/ietf/templates/group/group_stats_modal.html b/ietf/templates/group/group_stats_modal.html new file mode 100644 index 000000000..6431b675b --- /dev/null +++ b/ietf/templates/group/group_stats_modal.html @@ -0,0 +1,45 @@ + + \ No newline at end of file