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:
parent
42096989ec
commit
d6d1525da2
|
@ -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()
|
||||
|
|
|
@ -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"}),
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
45
ietf/templates/group/group_stats_modal.html
Normal file
45
ietf/templates/group/group_stats_modal.html
Normal 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>
|
Loading…
Reference in a new issue