+ {% block title %}
+ Statistics for IETF-{{ meeting_number }} Registrations
+ {% endblock %}
+
+
+ This page provides a visual representation of the total registrations for IETF-{{ meeting_number }} by country.
+ Only countries having more than {{ minimum_required }} registrations are displayed separately,
+ else they are grouped under "Other".
+
+
+
+
Total Registrations by Country
+
+
+
+
+
+
In Person Registrations by Country
+
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
From a0fd930a39223e65d721ebabd6f56b6ea85a57df Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Sat, 14 Mar 2026 06:55:49 +0000
Subject: [PATCH 02/34] Add totals, + nicer JS code
---
ietf/stats/views.py | 14 +++--
ietf/templates/stats/meeting_stats.html | 70 +++++++++----------------
2 files changed, 35 insertions(+), 49 deletions(-)
diff --git a/ietf/stats/views.py b/ietf/stats/views.py
index bc43859e986..5365cffd39e 100644
--- a/ietf/stats/views.py
+++ b/ietf/stats/views.py
@@ -152,7 +152,9 @@ def get_data_for_meeting(meeting_number, minimum_required, attendance_type=None)
labels = []
data = []
others_count = 0
+ total = 0
for item in registration_counts:
+ total += item['count']
if item['count'] > minimum_required:
labels.append(item['country_code'])
data.append(item['count'])
@@ -163,7 +165,7 @@ def get_data_for_meeting(meeting_number, minimum_required, attendance_type=None)
labels.append('Other')
data.append(others_count)
- return labels, data
+ return labels, data, total
def meeting_stats(request, meeting_number=None, stats_type=None):
minimum_required = 10
@@ -171,14 +173,14 @@ def meeting_stats(request, meeting_number=None, stats_type=None):
if meeting_number is None:
meeting_number = 125 # Will obvioulsy need to be dynamic
- total_labels, total_data = get_data_for_meeting(meeting_number, minimum_required)
- in_person_labels, in_person_data = get_data_for_meeting(meeting_number, minimum_required, attendance_type='onsite')
+ total_labels, total_data, total_total = get_data_for_meeting(meeting_number, minimum_required)
+ in_person_labels, in_person_data, in_person_total = get_data_for_meeting(meeting_number, minimum_required, attendance_type='onsite')
# Serialize to JSON for safe injection into the template
total_chart_data = json.dumps({
'labels': total_labels,
'datasets': [{
- 'label': 'TotalRegistrations by Country',
+ 'label': 'Total Registrations by Country',
'data': total_data,
'borderColor': '#ffffff',
'borderWidth': 2,
@@ -197,7 +199,9 @@ def meeting_stats(request, meeting_number=None, stats_type=None):
"meeting_number": meeting_number,
"minimum_required": minimum_required,
"total_chart_data": total_chart_data,
- "in_person_chart_data": in_person_chart_data
+ "total_total": total_total,
+ "in_person_chart_data": in_person_chart_data,
+ "in_person_total": in_person_total
})
diff --git a/ietf/templates/stats/meeting_stats.html b/ietf/templates/stats/meeting_stats.html
index cb262ca2bd1..c212040624f 100644
--- a/ietf/templates/stats/meeting_stats.html
+++ b/ietf/templates/stats/meeting_stats.html
@@ -21,14 +21,14 @@
-
Total Registrations by Country
-
+
Total Registrations by Country ({{ total_total}} in total)
+
-
In Person Registrations by Country
-
+
In Person Registrations by Country ({{ in_person_total}} in total)
+
@@ -42,49 +42,31 @@
In Person Registrations by Country
const totalChartData = JSON.parse('{{ total_chart_data|escapejs }}');
const inPersonChartData = JSON.parse('{{ in_person_chart_data|escapejs }}');
- const totalCtx = document.getElementById('totalRegistrationChart').getContext('2d');
- const inPersonCtx = document.getElementById('inPersonRegistrationChart').getContext('2d');
-
- new Chart(totalCtx, {
- type: 'pie', // Change to 'doughnut' for a donut chart
- data: totalChartData,
- options: {
- responsive: true,
- plugins: {
- autocolors: {
- mode: 'data' // Required for Pie charts to color individual slices
- },
- legend: {
- position: 'bottom',
- labels: {
- padding: 20,
- font: { size: 13 },
- color: '#475569',
+ function displayChart(id, data) {
+ const ctx = document.getElementById(id).getContext('2d');
+ new Chart(ctx, {
+ type: 'pie', // Change to 'doughnut' for a donut chart
+ data: data,
+ options: {
+ responsive: true,
+ plugins: {
+ autocolors: {
+ mode: 'data' // Required for Pie charts to color individual slices
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ padding: 20,
+ font: { size: 13 },
+ color: '#475569',
+ }
}
}
}
- }
- });
+ });
+ }
- new Chart(inPersonCtx, {
- type: 'pie', // Change to 'doughnut' for a donut chart
- data: inPersonChartData,
- options: {
- responsive: true,
- plugins: {
- autocolors: {
- mode: 'data' // Required for Pie charts to color individual slices
- },
- legend: {
- position: 'bottom',
- labels: {
- padding: 20,
- font: { size: 13 },
- color: '#475569',
- }
- }
- }
- }
- });
+ displayChart('totalRegistrationChart', totalChartData) ;
+ displayChart('inPersonRegistrationChart', inPersonChartData) ;
{% endblock %}
\ No newline at end of file
From 7dfa8d40e3f9607e723d99eb869882f378540a4d Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Sat, 14 Mar 2026 07:00:31 +0000
Subject: [PATCH 03/34] Coherent URL parameters
---
ietf/stats/urls.py | 2 +-
ietf/stats/views.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/ietf/stats/urls.py b/ietf/stats/urls.py
index d2993759d2a..cff119b77c4 100644
--- a/ietf/stats/urls.py
+++ b/ietf/stats/urls.py
@@ -11,7 +11,7 @@
url(r"^$", views.stats_index),
url(r"^document/(?:(?Pauthors|pages|words|format|formlang|author/(?:documents|affiliation|country|continent|citations|hindex)|yearly/(?:affiliation|country|continent))/)?$", views.document_stats),
url(r"^knowncountries/$", views.known_countries_list),
- url(r"^meeting/(?P\d+)/(?Pcountry|continent)/$", views.meeting_stats),
+ url(r"^meeting/(?P\d+)/(?Pcountry|continent)/$", views.meeting_stats),
url(r"^meeting/(?:(?Poverview|country|continent)/)?$", views.meeting_stats),
url(r"^review/(?:(?Pcompletion|results|states|time)/)?(?:%(acronym)s/)?$" % settings.URL_REGEXPS, views.review_stats),
]
diff --git a/ietf/stats/views.py b/ietf/stats/views.py
index 5365cffd39e..1f700ed1942 100644
--- a/ietf/stats/views.py
+++ b/ietf/stats/views.py
@@ -167,7 +167,7 @@ def get_data_for_meeting(meeting_number, minimum_required, attendance_type=None)
return labels, data, total
-def meeting_stats(request, meeting_number=None, stats_type=None):
+def meeting_stats(request, meeting_number=None, stats_type='country'):
minimum_required = 10
if meeting_number is None:
From 25c79a53ea49881c1083437b663055911f83146d Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Sat, 14 Mar 2026 07:26:52 +0000
Subject: [PATCH 04/34] Handle error case when per-continent stats is requested
---
ietf/stats/views.py | 3 +++
ietf/templates/stats/index.html | 5 ++++-
2 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/ietf/stats/views.py b/ietf/stats/views.py
index 1f700ed1942..b7891367c61 100644
--- a/ietf/stats/views.py
+++ b/ietf/stats/views.py
@@ -173,6 +173,9 @@ def meeting_stats(request, meeting_number=None, stats_type='country'):
if meeting_number is None:
meeting_number = 125 # Will obvioulsy need to be dynamic
+ if stats_type != 'country':
+ return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
+
total_labels, total_data, total_total = get_data_for_meeting(meeting_number, minimum_required)
in_person_labels, in_person_data, in_person_total = get_data_for_meeting(meeting_number, minimum_required, attendance_type='onsite')
diff --git a/ietf/templates/stats/index.html b/ietf/templates/stats/index.html
index 2000168525d..b3b7b425591 100644
--- a/ietf/templates/stats/index.html
+++ b/ietf/templates/stats/index.html
@@ -14,8 +14,11 @@
- Statistics on meetings and authorship are not currently available.
+ Statistics on authorship or per continent meeting are not currently available.
{% endblock %}
\ No newline at end of file
From 557291f75e203360ee280a9a588d1db2eddc31a1 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Sat, 14 Mar 2026 07:37:09 +0000
Subject: [PATCH 05/34] Remove redundant code
---
ietf/stats/views.py | 4 ----
1 file changed, 4 deletions(-)
diff --git a/ietf/stats/views.py b/ietf/stats/views.py
index b7891367c61..7f8a405312b 100644
--- a/ietf/stats/views.py
+++ b/ietf/stats/views.py
@@ -145,10 +145,6 @@ def get_data_for_meeting(meeting_number, minimum_required, attendance_type=None)
registration_counts = registration_counts.filter(tickets__attendance_type=attendance_type)
registration_counts = registration_counts.values('country_code').annotate(count=Count('country_code')).order_by('-count')
- # Prepare data for the pie chart
- labels = [item['country_code'] for item in registration_counts]
- data = [item['count'] for item in registration_counts]
-
labels = []
data = []
others_count = 0
From ebc4bdc90148c6c6ecb80efc92918ab88dbeac6f Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Sat, 14 Mar 2026 07:46:43 +0000
Subject: [PATCH 06/34] Dynamic get the current IETF meeting
---
ietf/stats/views.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/ietf/stats/views.py b/ietf/stats/views.py
index 7f8a405312b..480f710abba 100644
--- a/ietf/stats/views.py
+++ b/ietf/stats/views.py
@@ -26,10 +26,11 @@
from ietf.group.models import Role, Group
from ietf.person.models import Person
from ietf.name.models import ReviewResultName, CountryName, ReviewAssignmentStateName
+from ietf.meeting.models import Registration
from ietf.ietfauth.utils import has_role
from ietf.utils.response import permission_denied
from ietf.utils.timezone import date_today, DEADLINE_TZINFO
-from ietf.meeting.models import Registration
+from ietf.meeting.helpers import get_current_ietf_meeting_num
def stats_index(request):
@@ -167,7 +168,7 @@ def meeting_stats(request, meeting_number=None, stats_type='country'):
minimum_required = 10
if meeting_number is None:
- meeting_number = 125 # Will obvioulsy need to be dynamic
+ meeting_number = get_current_ietf_meeting_num()
if stats_type != 'country':
return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
From 2e556431be9eb221181a9315165b4befc691055e Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Sat, 14 Mar 2026 08:07:44 +0000
Subject: [PATCH 07/34] Display %-age when hovering
---
ietf/templates/stats/meeting_stats.html | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/ietf/templates/stats/meeting_stats.html b/ietf/templates/stats/meeting_stats.html
index c212040624f..f4732d37ed9 100644
--- a/ietf/templates/stats/meeting_stats.html
+++ b/ietf/templates/stats/meeting_stats.html
@@ -60,6 +60,18 @@
In Person Registrations by Country ({{ in_person_total}} in total)
font: { size: 13 },
color: '#475569',
}
+ },
+ tooltip: {
+ callbacks: {
+ label: function(context) {
+ const label = context.label || '';
+ const value = context.raw;
+ const total = context.dataset.data.reduce((a, b) => a + b, 0);
+ const percentage = ((value / total) * 100).toFixed(1);
+
+ return `${label}: ${value} (${percentage}%)`;
+ }
+ }
}
}
}
From ac4f28e0d0e6affa0f183637069ced27293c7a42 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Sun, 15 Mar 2026 06:47:31 +0000
Subject: [PATCH 08/34] Add test for meeting statistics
---
ietf/stats/tests.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/ietf/stats/tests.py b/ietf/stats/tests.py
index 48552c8fbac..635637d39c4 100644
--- a/ietf/stats/tests.py
+++ b/ietf/stats/tests.py
@@ -33,9 +33,9 @@ def test_document_stats(self):
def test_meeting_stats(self):
- r = self.client.get(urlreverse("ietf.stats.views.meeting_stats"))
- self.assertRedirects(r, urlreverse("ietf.stats.views.stats_index"))
-
+ r = self.client.get(urlreverse("ietf.stats.views.meeting_stats", kwargs=dict(meeting_number=124,stats_type="country")))
+ self.assertEqual(r.status_code, 200)
+ self.assertContains(r, "Total Registrations by Country (1885 in total)")
def test_known_country_list(self):
# check redirect
From 62a6784a180687184d6a3e4390442616cbbbae55 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Mon, 16 Mar 2026 06:23:50 +0000
Subject: [PATCH 09/34] Add test
---
ietf/stats/tests.py | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/ietf/stats/tests.py b/ietf/stats/tests.py
index 635637d39c4..aeb857c52b4 100644
--- a/ietf/stats/tests.py
+++ b/ietf/stats/tests.py
@@ -18,6 +18,7 @@
from ietf.group.factories import RoleFactory
from ietf.person.factories import PersonFactory
from ietf.review.factories import ReviewRequestFactory, ReviewerSettingsFactory, ReviewAssignmentFactory
+from ietf.meeting.tests_models import MeetingFactory, RegistrationFactory
from ietf.utils.timezone import date_today
@@ -33,9 +34,15 @@ def test_document_stats(self):
def test_meeting_stats(self):
- r = self.client.get(urlreverse("ietf.stats.views.meeting_stats", kwargs=dict(meeting_number=124,stats_type="country")))
+ meeting = MeetingFactory(type_id='ietf', number='124')
+ RegistrationFactory.create_batch(5, meeting=meeting, with_ticket={'attendance_type_id': 'onsite'}, attended=True)
+ RegistrationFactory(meeting=meeting, with_ticket={'attendance_type_id': 'onsite'}, attended=False)
+ RegistrationFactory.create_batch(4, meeting=meeting, with_ticket={'attendance_type_id': 'remote'}, attended=True)
+ RegistrationFactory(meeting=meeting, with_ticket={'attendance_type_id': 'remote'}, attended=False)
+ r = self.client.get(urlreverse("ietf.stats.views.meeting_stats", kwargs={'meeting_number': '124','stats_type': 'country'}))
self.assertEqual(r.status_code, 200)
- self.assertContains(r, "Total Registrations by Country (1885 in total)")
+ self.assertContains(r, "Total Registrations by Country (11 in total)")
+ self.assertContains(r, "In Person Registrations by Country (6 in total)")
def test_known_country_list(self):
# check redirect
From 14780b915964570496eec98b340457471c8528a4 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Mon, 16 Mar 2026 07:09:01 +0000
Subject: [PATCH 10/34] Add statistics per affiliation
---
ietf/stats/urls.py | 2 +-
ietf/stats/views.py | 84 +++++++++++++++++++++++--
ietf/templates/stats/meeting_stats.html | 8 +--
3 files changed, 83 insertions(+), 11 deletions(-)
diff --git a/ietf/stats/urls.py b/ietf/stats/urls.py
index cff119b77c4..2493eb23976 100644
--- a/ietf/stats/urls.py
+++ b/ietf/stats/urls.py
@@ -11,7 +11,7 @@
url(r"^$", views.stats_index),
url(r"^document/(?:(?Pauthors|pages|words|format|formlang|author/(?:documents|affiliation|country|continent|citations|hindex)|yearly/(?:affiliation|country|continent))/)?$", views.document_stats),
url(r"^knowncountries/$", views.known_countries_list),
- url(r"^meeting/(?P\d+)/(?Pcountry|continent)/$", views.meeting_stats),
+ url(r"^meeting/(?P\d+)/(?Paffiliation|country|continent)/$", views.meeting_stats),
url(r"^meeting/(?:(?Poverview|country|continent)/)?$", views.meeting_stats),
url(r"^review/(?:(?Pcompletion|results|states|time)/)?(?:%(acronym)s/)?$" % settings.URL_REGEXPS, views.review_stats),
]
diff --git a/ietf/stats/views.py b/ietf/stats/views.py
index 480f710abba..0d89e19080f 100644
--- a/ietf/stats/views.py
+++ b/ietf/stats/views.py
@@ -139,6 +139,72 @@ def known_countries_list(request, stats_type=None, acronym=None):
"countries": countries,
})
+def canonicalize_affiliation(affiliation):
+ if not affiliation:
+ return None
+ if affiliation.endswith(" AB"):
+ affiliation = affiliation[:-3]
+ if affiliation.endswith(" AG"):
+ affiliation = affiliation[:-3]
+ if affiliation.endswith(" Corp"):
+ affiliation = affiliation[:-5]
+ if affiliation.endswith(" Corporation"):
+ affiliation = affiliation[:-11]
+ if affiliation.endswith(", Inc."):
+ affiliation = affiliation[:-6]
+ if affiliation.endswith(" GmbH"):
+ affiliation = affiliation[:-5]
+ if affiliation.endswith(", Inc"):
+ affiliation = affiliation[:-5]
+ if affiliation.endswith(" Inc."):
+ affiliation = affiliation[:-5]
+ if affiliation.endswith(" Inc"):
+ affiliation = affiliation[:-4]
+ if affiliation.endswith(" LLC"):
+ affiliation = affiliation[:-4]
+ if affiliation == 'Akamai Technologies':
+ affiliation = 'Akamai'
+ if affiliation == 'Google Inc.':
+ affiliation = 'Google'
+ if affiliation == 'Cisco Systems':
+ affiliation = 'Cisco'
+ if affiliation == 'Futurewei Technologies':
+ affiliation = 'Futurewei'
+ if affiliation == 'Huawei Technologies':
+ affiliation = 'Huawei'
+ return affiliation
+
+def get_affiliation_data_for_meeting(meeting_number, minimum_required, attendance_type=None):
+ # Get registration status counts
+ registrations = Registration.objects.filter(meeting__number=meeting_number)
+ if attendance_type:
+ registrations = registrations.filter(tickets__attendance_type=attendance_type)
+ registrations = registrations.values('affiliation')
+
+ organization = dict()
+ for reg in registrations:
+ affiliation = canonicalize_affiliation(reg['affiliation']) or "Unspecified"
+ organization[affiliation] = organization.get(affiliation, 0) + 1
+
+ sorted_orgs = sorted(organization.items(), key=lambda t: t[1], reverse=True)
+ labels = []
+ data = []
+ others_count = 0
+ total = 0
+ for org, count in sorted_orgs:
+ total += count
+ if count > minimum_required:
+ labels.append(org)
+ data.append(count)
+ else:
+ others_count += count
+
+ if others_count > 0:
+ labels.append('Other')
+ data.append(others_count)
+
+ return labels, data, total
+
def get_data_for_meeting(meeting_number, minimum_required, attendance_type=None):
# Get registration status counts
registration_counts = Registration.objects.filter(meeting__number=meeting_number)
@@ -165,22 +231,27 @@ def get_data_for_meeting(meeting_number, minimum_required, attendance_type=None)
return labels, data, total
def meeting_stats(request, meeting_number=None, stats_type='country'):
- minimum_required = 10
if meeting_number is None:
meeting_number = get_current_ietf_meeting_num()
- if stats_type != 'country':
+ if stats_type == 'affiliation':
+ minimum_required = 5
+ total_labels, total_data, total_total = get_affiliation_data_for_meeting(meeting_number, minimum_required)
+ in_person_labels, in_person_data, in_person_total = get_affiliation_data_for_meeting(meeting_number, minimum_required, attendance_type='onsite')
+ elif stats_type == 'country':
+ minimum_required = 10
+ total_labels, total_data, total_total = get_data_for_meeting(meeting_number, minimum_required)
+ in_person_labels, in_person_data, in_person_total = get_data_for_meeting(meeting_number, minimum_required, attendance_type='onsite')
+ else:
return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
- total_labels, total_data, total_total = get_data_for_meeting(meeting_number, minimum_required)
- in_person_labels, in_person_data, in_person_total = get_data_for_meeting(meeting_number, minimum_required, attendance_type='onsite')
# Serialize to JSON for safe injection into the template
total_chart_data = json.dumps({
'labels': total_labels,
'datasets': [{
- 'label': 'Total Registrations by Country',
+ 'label': 'Total Registrations by ' + stats_type,
'data': total_data,
'borderColor': '#ffffff',
'borderWidth': 2,
@@ -189,7 +260,7 @@ def meeting_stats(request, meeting_number=None, stats_type='country'):
in_person_chart_data = json.dumps({
'labels': in_person_labels,
'datasets': [{
- 'label': 'In Person Registrations by Country',
+ 'label': 'In Person Registrations by ' + stats_type,
'data': in_person_data,
'borderColor': '#ffffff',
'borderWidth': 2,
@@ -197,6 +268,7 @@ def meeting_stats(request, meeting_number=None, stats_type='country'):
})
return render(request, "stats/meeting_stats.html", {
"meeting_number": meeting_number,
+ "stats_type": stats_type,
"minimum_required": minimum_required,
"total_chart_data": total_chart_data,
"total_total": total_total,
diff --git a/ietf/templates/stats/meeting_stats.html b/ietf/templates/stats/meeting_stats.html
index f4732d37ed9..6fac13efcc2 100644
--- a/ietf/templates/stats/meeting_stats.html
+++ b/ietf/templates/stats/meeting_stats.html
@@ -15,19 +15,19 @@
{% endblock %}
- This page provides a visual representation of the total registrations for IETF-{{ meeting_number }} by country.
- Only countries having more than {{ minimum_required }} registrations are displayed separately,
+ This page provides a visual representation of the total registrations for IETF-{{ meeting_number }} by {{ stats_type }}.
+ Only categories having more than {{ minimum_required }} registrations are displayed separately,
else they are grouped under "Other".
-
Total Registrations by Country ({{ total_total}} in total)
+
Total Registrations by {{ stats_type|title }} ({{ total_total}} in total)
-
In Person Registrations by Country ({{ in_person_total}} in total)
+
In Person Registrations by {{ stats_type|title }} ({{ in_person_total}} in total)
From 90aa3acea9b551bae71d2e83e142a6cc56a82310 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Mon, 16 Mar 2026 07:58:08 +0000
Subject: [PATCH 11/34] More code coverage for test
---
ietf/stats/tests.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/ietf/stats/tests.py b/ietf/stats/tests.py
index aeb857c52b4..36c1c60d1fa 100644
--- a/ietf/stats/tests.py
+++ b/ietf/stats/tests.py
@@ -39,6 +39,10 @@ def test_meeting_stats(self):
RegistrationFactory(meeting=meeting, with_ticket={'attendance_type_id': 'onsite'}, attended=False)
RegistrationFactory.create_batch(4, meeting=meeting, with_ticket={'attendance_type_id': 'remote'}, attended=True)
RegistrationFactory(meeting=meeting, with_ticket={'attendance_type_id': 'remote'}, attended=False)
+ r = self.client.get(urlreverse("ietf.stats.views.meeting_stats", kwargs={'meeting_number': '124','stats_type': 'affiliation'}))
+ self.assertEqual(r.status_code, 200)
+ self.assertContains(r, "Total Registrations by Affiliation (11 in total)")
+ self.assertContains(r, "In Person Registrations by Affiliation (6 in total)")
r = self.client.get(urlreverse("ietf.stats.views.meeting_stats", kwargs={'meeting_number': '124','stats_type': 'country'}))
self.assertEqual(r.status_code, 200)
self.assertContains(r, "Total Registrations by Country (11 in total)")
From b159b48ad61b96f7ac1c985052e7090fae5c4784 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Mon, 16 Mar 2026 09:50:28 +0000
Subject: [PATCH 12/34] Nicer canonical affiliation
---
ietf/stats/views.py | 29 +++++++----------------------
1 file changed, 7 insertions(+), 22 deletions(-)
diff --git a/ietf/stats/views.py b/ietf/stats/views.py
index 0d89e19080f..f4e99b84ec1 100644
--- a/ietf/stats/views.py
+++ b/ietf/stats/views.py
@@ -142,26 +142,11 @@ def known_countries_list(request, stats_type=None, acronym=None):
def canonicalize_affiliation(affiliation):
if not affiliation:
return None
- if affiliation.endswith(" AB"):
- affiliation = affiliation[:-3]
- if affiliation.endswith(" AG"):
- affiliation = affiliation[:-3]
- if affiliation.endswith(" Corp"):
- affiliation = affiliation[:-5]
- if affiliation.endswith(" Corporation"):
- affiliation = affiliation[:-11]
- if affiliation.endswith(", Inc."):
- affiliation = affiliation[:-6]
- if affiliation.endswith(" GmbH"):
- affiliation = affiliation[:-5]
- if affiliation.endswith(", Inc"):
- affiliation = affiliation[:-5]
- if affiliation.endswith(" Inc."):
- affiliation = affiliation[:-5]
- if affiliation.endswith(" Inc"):
- affiliation = affiliation[:-4]
- if affiliation.endswith(" LLC"):
- affiliation = affiliation[:-4]
+ for suffix in ('ab', 'ag', 'corp', 'corp.', 'corporation', 'gmbh', 'inc.', 'inc', 'llc'):
+ if affiliation.lower().endswith(' ' + suffix):
+ affiliation[:-(len(suffix)+1)]
+ if affiliation.lower().endswith(', ' + suffix):
+ affiliation[:-(len(suffix)+2)]
if affiliation == 'Akamai Technologies':
affiliation = 'Akamai'
if affiliation == 'Google Inc.':
@@ -172,7 +157,7 @@ def canonicalize_affiliation(affiliation):
affiliation = 'Futurewei'
if affiliation == 'Huawei Technologies':
affiliation = 'Huawei'
- return affiliation
+ return affiliation.title()
def get_affiliation_data_for_meeting(meeting_number, minimum_required, attendance_type=None):
# Get registration status counts
@@ -236,7 +221,7 @@ def meeting_stats(request, meeting_number=None, stats_type='country'):
meeting_number = get_current_ietf_meeting_num()
if stats_type == 'affiliation':
- minimum_required = 5
+ minimum_required = 4
total_labels, total_data, total_total = get_affiliation_data_for_meeting(meeting_number, minimum_required)
in_person_labels, in_person_data, in_person_total = get_affiliation_data_for_meeting(meeting_number, minimum_required, attendance_type='onsite')
elif stats_type == 'country':
From b0d284203ae840219aa64fe20168d71c47261d00 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Mon, 16 Mar 2026 13:25:48 +0000
Subject: [PATCH 13/34] Allow navigation by buttons
---
ietf/stats/views.py | 50 +++++++++++++++++--------
ietf/templates/stats/meeting_stats.html | 24 +++++++++++-
2 files changed, 58 insertions(+), 16 deletions(-)
diff --git a/ietf/stats/views.py b/ietf/stats/views.py
index f4e99b84ec1..f8ca535264a 100644
--- a/ietf/stats/views.py
+++ b/ietf/stats/views.py
@@ -30,7 +30,7 @@
from ietf.ietfauth.utils import has_role
from ietf.utils.response import permission_denied
from ietf.utils.timezone import date_today, DEADLINE_TZINFO
-from ietf.meeting.helpers import get_current_ietf_meeting_num
+from ietf.meeting.helpers import get_current_ietf_meeting_num, get_ietf_meeting
def stats_index(request):
@@ -142,35 +142,32 @@ def known_countries_list(request, stats_type=None, acronym=None):
def canonicalize_affiliation(affiliation):
if not affiliation:
return None
- for suffix in ('ab', 'ag', 'corp', 'corp.', 'corporation', 'gmbh', 'inc.', 'inc', 'llc'):
+ for suffix in ('ab', 'ag', 'corp', 'corp.', 'corporation', 'gmbh', 'inc.', 'inc', 'international pte ltd', 'llc', 'ltd', 'ltd.', 'private limited', 'pty ltd', 'pvt ltd'):
if affiliation.lower().endswith(' ' + suffix):
affiliation[:-(len(suffix)+1)]
+ if affiliation.lower().endswith(',' + suffix):
+ affiliation[:-(len(suffix)+1)]
if affiliation.lower().endswith(', ' + suffix):
affiliation[:-(len(suffix)+2)]
- if affiliation == 'Akamai Technologies':
- affiliation = 'Akamai'
- if affiliation == 'Google Inc.':
- affiliation = 'Google'
- if affiliation == 'Cisco Systems':
- affiliation = 'Cisco'
- if affiliation == 'Futurewei Technologies':
- affiliation = 'Futurewei'
- if affiliation == 'Huawei Technologies':
- affiliation = 'Huawei'
+ for prefix in ('akamai','apple', 'cisco', 'futurewei', 'google', 'hpe', 'huawei', 'meta', 'nokia', 'siemens'):
+ if affiliation.lower().startswith(prefix + ' '):
+ affiliation = prefix
return affiliation.title()
def get_affiliation_data_for_meeting(meeting_number, minimum_required, attendance_type=None):
- # Get registration status counts
+ # Get registration status details
registrations = Registration.objects.filter(meeting__number=meeting_number)
if attendance_type:
registrations = registrations.filter(tickets__attendance_type=attendance_type)
registrations = registrations.values('affiliation')
+ # Count per canonicalized affiliation
organization = dict()
for reg in registrations:
affiliation = canonicalize_affiliation(reg['affiliation']) or "Unspecified"
organization[affiliation] = organization.get(affiliation, 0) + 1
+ # Sort to have the largest count first (nicer in pie chart)
sorted_orgs = sorted(organization.items(), key=lambda t: t[1], reverse=True)
labels = []
data = []
@@ -191,7 +188,7 @@ def get_affiliation_data_for_meeting(meeting_number, minimum_required, attendanc
return labels, data, total
def get_data_for_meeting(meeting_number, minimum_required, attendance_type=None):
- # Get registration status counts
+ # Get registration status counts, aggregated by country_code
registration_counts = Registration.objects.filter(meeting__number=meeting_number)
if attendance_type:
registration_counts = registration_counts.filter(tickets__attendance_type=attendance_type)
@@ -217,8 +214,11 @@ def get_data_for_meeting(meeting_number, minimum_required, attendance_type=None)
def meeting_stats(request, meeting_number=None, stats_type='country'):
+ current_meeting = get_current_ietf_meeting_num()
if meeting_number is None:
- meeting_number = get_current_ietf_meeting_num()
+ meeting_number = current_meeting
+
+ this_meeting = get_ietf_meeting(meeting_number)
if stats_type == 'affiliation':
minimum_required = 4
@@ -251,8 +251,28 @@ def meeting_stats(request, meeting_number=None, stats_type='country'):
'borderWidth': 2,
}]
})
+
+ # Prepare the list of choice buttons for the template
+ possible_stats_types = [
+ ("affiliation", "Per affiliation", urlreverse(meeting_stats, kwargs={'meeting_number': meeting_number, 'stats_type': 'affiliation'})),
+ ("country", "Per country", urlreverse(meeting_stats, kwargs={'meeting_number': meeting_number, 'stats_type': 'country'})),
+ ]
+
+ # Prepare the list of meeting number buttons for the template
+ possible_meeting_numbers = []
+ if int(meeting_number) > 72: # No registration data before IETF-72
+ possible_meeting_numbers.append((int(meeting_number)-1, urlreverse(meeting_stats, kwargs={'meeting_number': int(meeting_number)-1, 'stats_type': stats_type})))
+ possible_meeting_numbers.append((meeting_number, urlreverse(meeting_stats, kwargs={'meeting_number': meeting_number, 'stats_type': stats_type})))
+ if int(meeting_number) <= int(current_meeting): # Allow current meeting +1
+ possible_meeting_numbers.append((int(meeting_number)+1, urlreverse(meeting_stats, kwargs={'meeting_number': int(meeting_number)+1, 'stats_type': stats_type})))
+
return render(request, "stats/meeting_stats.html", {
"meeting_number": meeting_number,
+ "meeting_date": this_meeting.date,
+ "meeting_country": this_meeting.country,
+ "meeting_city": this_meeting.city,
+ "possible_stats_types": possible_stats_types,
+ "possible_meeting_numbers": possible_meeting_numbers,
"stats_type": stats_type,
"minimum_required": minimum_required,
"total_chart_data": total_chart_data,
diff --git a/ietf/templates/stats/meeting_stats.html b/ietf/templates/stats/meeting_stats.html
index 6fac13efcc2..3bdb060bc0f 100644
--- a/ietf/templates/stats/meeting_stats.html
+++ b/ietf/templates/stats/meeting_stats.html
@@ -11,9 +11,31 @@
{% origin %}
{% block title %}
- Statistics for IETF-{{ meeting_number }} Registrations
+ Statistics for IETF-{{ meeting_number }} ({{ meeting_date }}, {{ meeting_city }}, {{ meeting_country }}) Registrations
{% endblock %}
+
+
+
+ {% for slug, label, url in possible_stats_types %}
+ {{ label }}
+ {% endfor %}
+
+
+
+ {% for num, url in possible_meeting_numbers %}
+ {{ num }}
+ {% endfor %}
+
+
This page provides a visual representation of the total registrations for IETF-{{ meeting_number }} by {{ stats_type }}.
Only categories having more than {{ minimum_required }} registrations are displayed separately,
From e41a545b66b97d2b59b8247e984e97d176a68ba2 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Tue, 17 Mar 2026 07:39:08 +0000
Subject: [PATCH 14/34] Also add participants count in the legend
---
ietf/templates/stats/meeting_stats.html | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/ietf/templates/stats/meeting_stats.html b/ietf/templates/stats/meeting_stats.html
index 3bdb060bc0f..70ce363f7ab 100644
--- a/ietf/templates/stats/meeting_stats.html
+++ b/ietf/templates/stats/meeting_stats.html
@@ -81,6 +81,15 @@
In Person Registrations by {{ stats_type|title }} ({{ in_person_total}} in t
padding: 20,
font: { size: 13 },
color: '#475569',
+ generateLabels: function(chart) {
+ const dataset = chart.data.datasets[0];
+ return chart.data.labels.map((label, i) => ({
+ text : `${label}: ${dataset.data[i]}`,
+ fillStyle : dataset.backgroundColor[i],
+ hidden : false,
+ index : i,
+ }));
+ }
}
},
tooltip: {
From 19233d4630f2255268f498b38765b3d6086b40eb Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Tue, 24 Mar 2026 16:01:22 +0000
Subject: [PATCH 15/34] Add timeline over meetings (total and per country)
---
ietf/stats/urls.py | 5 +-
ietf/stats/views.py | 183 +++++++++++++++++++-
ietf/templates/base/menu.html | 2 +-
ietf/templates/stats/meetings_timeline.html | 117 +++++++++++++
4 files changed, 303 insertions(+), 4 deletions(-)
create mode 100644 ietf/templates/stats/meetings_timeline.html
diff --git a/ietf/stats/urls.py b/ietf/stats/urls.py
index 2493eb23976..01b8758c840 100644
--- a/ietf/stats/urls.py
+++ b/ietf/stats/urls.py
@@ -11,7 +11,8 @@
url(r"^$", views.stats_index),
url(r"^document/(?:(?Pauthors|pages|words|format|formlang|author/(?:documents|affiliation|country|continent|citations|hindex)|yearly/(?:affiliation|country|continent))/)?$", views.document_stats),
url(r"^knowncountries/$", views.known_countries_list),
- url(r"^meeting/(?P\d+)/(?Paffiliation|country|continent)/$", views.meeting_stats),
- url(r"^meeting/(?:(?Poverview|country|continent)/)?$", views.meeting_stats),
+ url(r"^meeting/$", views.meetings_timeline),
+ url(r"^meeting/(?P\d+)/(?Paffiliation|country)/$", views.meeting_stats),
+ url(r"^meeting/(?:(?Paffiliation|country|total)/)?$", views.meetings_timeline),
url(r"^review/(?:(?Pcompletion|results|states|time)/)?(?:%(acronym)s/)?$" % settings.URL_REGEXPS, views.review_stats),
]
diff --git a/ietf/stats/views.py b/ietf/stats/views.py
index f8ca535264a..222b02d33f8 100644
--- a/ietf/stats/views.py
+++ b/ietf/stats/views.py
@@ -154,6 +154,187 @@ def canonicalize_affiliation(affiliation):
affiliation = prefix
return affiliation.title()
+def get_country_data_for_meetings(top_n, attendance_type=None):
+ # Get registration status counts, aggregated by country_code
+ if attendance_type:
+ registrations = Registration.objects.filter(tickets__attendance_type=attendance_type)
+ else:
+ registrations = Registration.objects.all()
+ queryset = (
+ registrations
+ .values(
+ 'meeting__number', # e.g. "118", "119", "120"
+# 'meeting__date', # meeting start date
+ 'country_code' # country code of the participant
+ )
+ .annotate(participant_count=Count('id'))
+ .order_by('meeting__number') # chronological order
+ )
+
+# ── Step 1: Collect all meetings and country totals ──
+ meetings_set = set()
+ country_totals = defaultdict(int)
+ data_map = defaultdict(dict) # {country: {meeting: count}}
+
+ for row in queryset:
+ meeting = row['meeting__number']
+ country = row['country_code']
+ count = row['participant_count']
+
+ meetings_set.add(meeting)
+ country_totals[country] += count
+ data_map[country][meeting] = count
+
+ # ── Step 2: Sort meetings numerically rather than alphabetically ──
+ sorted_meetings = sorted(meetings_set, key=lambda x: int(x) if x.isdigit() else x)
+
+ # ── Step 3: Get top N countries ──
+ top_countries = sorted(
+ country_totals.keys(),
+ key=lambda c: country_totals[c],
+ reverse=True
+ )[:top_n]
+
+ # -- Step 3.bis do the 'other' category --
+ non_top_countries = country_totals.keys() - top_countries
+ other_totals = defaultdict(int)
+ for m in sorted_meetings:
+ other_totals[m] = 0
+ for c in non_top_countries:
+ other_totals[m] += int(data_map[c].get(m, 0))
+
+ # ── Step 4: Build Chart.js datasets ──
+ # Color palette for lines
+ colors = [
+ '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
+ '#FF9F40', '#C9CBCF', '#7BC043', '#F37735', '#00ABA9',
+ '#2B5797', '#E81123', '#00A4EF', '#7FBA00', '#FFB900',
+ '#D83B01', '#B4009E', '#5C2D91', '#008575', '#E3008C',
+ ]
+
+ datasets = []
+ for idx, country in enumerate(top_countries):
+ color = colors[idx % len(colors)]
+ datasets.append({
+ 'label': country,
+ 'data': [data_map[country].get(m, 0) for m in sorted_meetings],
+ 'borderColor': color,
+ 'fill': False,
+ 'tension': 0.3,
+ 'pointColor': color,
+ 'pointBackgroundColor': color,
+ 'pointRadius': 4,
+ 'pointHoverRadius': 6,
+ 'borderWidth': 2,
+ })
+
+ # -- Step 4.bis handle the other --
+ datasets.append({
+ 'label': 'Other',
+ 'data': [other_totals.get(m, 0) for m in sorted_meetings],
+ 'borderColor': 'black',
+ 'fill': False,
+ 'tension': 0.3,
+ 'pointColor': 'black',
+ 'pointBackgroundColor': 'black',
+ 'pointRadius': 4,
+ 'pointHoverRadius': 6,
+ 'borderWidth': 2,
+ })
+
+ return sorted_meetings, datasets
+
+def get_data_for_meetings(attendance_type=None):
+ # Get registration status counts, aggregated by country_code
+ if attendance_type:
+ registrations = Registration.objects.filter(tickets__attendance_type=attendance_type)
+ else:
+ registrations = Registration.objects.all()
+ queryset = (
+ registrations
+ .values(
+ 'meeting__number', # e.g. "118", "119", "120"
+ )
+ .annotate(participant_count=Count('id'))
+ .order_by('meeting__number') # chronological order
+ )
+
+ # ── Step 1: Collect all meetings totals ──
+
+ meetings_set = set()
+ meeting_totals = defaultdict(int)
+
+ for row in queryset:
+ meetings_set.add(row['meeting__number'])
+ meeting_totals[row['meeting__number']] = row['participant_count']
+
+ # ── Step 2: Sort meetings numerically rather than alphabetically ──
+ sorted_meetings = sorted(meetings_set, key=lambda x: int(x) if x.isdigit() else x)
+
+ datasets = [ {
+ 'label': 'Total participants',
+ 'data': [meeting_totals[m] for m in sorted_meetings],
+ 'borderColor': 'blue',
+ 'fill': False,
+ 'tension': 0.3,
+ 'pointColor': 'blue',
+ 'pointBackgroundColor': 'blue',
+ 'pointRadius': 4,
+ 'pointHoverRadius': 6,
+ 'borderWidth': 2,
+ }
+ ]
+
+ return sorted_meetings, datasets
+
+def meetings_timeline(request, stats_type='country', top_n=10):
+
+ if stats_type == 'total':
+ total_labels, total_data_sets = get_data_for_meetings()
+ in_person_labels, in_person_data_sets = get_data_for_meetings(attendance_type='onsite')
+ elif stats_type == 'country':
+ total_labels, total_data_sets = get_country_data_for_meetings(10)
+ in_person_labels, in_person_data_sets = get_country_data_for_meetings(10, attendance_type='onsite')
+ else:
+ return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
+
+ # Serialize to JSON for safe injection into the template
+ total_chart_data = json.dumps({
+ 'labels': total_labels,
+ 'datasets': total_data_sets,
+ })
+
+ in_person_chart_data = json.dumps({
+ 'labels': in_person_labels,
+ 'datasets': in_person_data_sets,
+ })
+
+ # Prepare the list of choice buttons for the template
+ possible_stats_types = [
+# TODO ("affiliation", "Per affiliation", urlreverse(meetings_timeline, kwargs={'stats_type': 'affiliation'})),
+ ("country", "Per country", urlreverse(meetings_timeline, kwargs={'stats_type': 'country'})),
+ ("total", "Total", urlreverse(meetings_timeline, kwargs={'stats_type': 'total'})),
+ ]
+
+ current_meeting = get_current_ietf_meeting_num()
+ if stats_type == 'total':
+ possible_stats_type = 'country'
+ else:
+ possible_stats_type = stats_type
+ possible_meeting_numbers = [(int(current_meeting)-1, urlreverse(meeting_stats, kwargs={'meeting_number': int(current_meeting)-1, 'stats_type': possible_stats_type})),
+ (int(current_meeting), urlreverse(meeting_stats, kwargs={'meeting_number': int(current_meeting), 'stats_type': possible_stats_type})),
+ (int(current_meeting)+1, urlreverse(meeting_stats, kwargs={'meeting_number': int(current_meeting)+1, 'stats_type': possible_stats_type}))]
+
+ return render(request, "stats/meetings_timeline.html", {
+ "top_n": top_n,
+ "possible_stats_types": possible_stats_types,
+ "possible_meeting_numbers": possible_meeting_numbers,
+ "stats_type": stats_type,
+ "total_chart_data": total_chart_data,
+ "in_person_chart_data": in_person_chart_data,
+ })
+
+
def get_affiliation_data_for_meeting(meeting_number, minimum_required, attendance_type=None):
# Get registration status details
registrations = Registration.objects.filter(meeting__number=meeting_number)
@@ -259,7 +440,7 @@ def meeting_stats(request, meeting_number=None, stats_type='country'):
]
# Prepare the list of meeting number buttons for the template
- possible_meeting_numbers = []
+ possible_meeting_numbers = [('All', urlreverse(meetings_timeline, kwargs={'stats_type': stats_type}))]
if int(meeting_number) > 72: # No registration data before IETF-72
possible_meeting_numbers.append((int(meeting_number)-1, urlreverse(meeting_stats, kwargs={'meeting_number': int(meeting_number)-1, 'stats_type': stats_type})))
possible_meeting_numbers.append((meeting_number, urlreverse(meeting_stats, kwargs={'meeting_number': meeting_number, 'stats_type': stats_type})))
diff --git a/ietf/templates/base/menu.html b/ietf/templates/base/menu.html
index 2bebd6f08af..43ca025e28b 100644
--- a/ietf/templates/base/menu.html
+++ b/ietf/templates/base/menu.html
@@ -441,7 +441,7 @@
+ {% block title %}
+ Statistics for IETF Meeting Registrations
+ {% endblock %}
+
+
+
+
+ {% for slug, label, url in possible_stats_types %}
+ {{ label }}
+ {% endfor %}
+
+
+
+ {% for num, url in possible_meeting_numbers %}
+ {{ num }}
+ {% endfor %}
+
+
+
+ {% if stats_type == 'total' %}
+ This page provides a timeline of meeting registrations.
+ {% else %}
+ This page provides a timeline of meeting registrations by {{ stats_type }} with a limit of {{ top_n }} categories.
+ {% endif %}
+
+
+
+ {% if stats_type == 'total' %}
+
Total Registrations
+ {% else %}
+
Total Registrations by {{ stats_type|title }}
+ {% endif %}
+
+
+
+
+
+ {% if stats_type == 'total' %}
+
Total In Person Registrations
+ {% else %}
+
In Person Registrations by {{ stats_type|title }}
+ {% endif %}
+
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
From 94e00ac7d7eedd745ed4b09d8e6998e9fe45bc57 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Wed, 25 Mar 2026 08:05:00 +0000
Subject: [PATCH 16/34] Default index refers to current meeting stats by number
---
ietf/stats/tests.py | 16 ++++++++++------
ietf/stats/views.py | 5 ++++-
ietf/templates/stats/index.html | 7 +++++--
3 files changed, 19 insertions(+), 9 deletions(-)
diff --git a/ietf/stats/tests.py b/ietf/stats/tests.py
index 36c1c60d1fa..f155a2f6e0c 100644
--- a/ietf/stats/tests.py
+++ b/ietf/stats/tests.py
@@ -10,6 +10,7 @@
import debug # pyflakes:ignore
from django.urls import reverse as urlreverse
+from django.utils import timezone
from ietf.utils.test_utils import login_testing_unauthorized, TestCase
import ietf.stats.views
@@ -24,26 +25,29 @@
class StatisticsTests(TestCase):
def test_stats_index(self):
+ # Create a meeting as the index page needs to know the current meeting
+ MeetingFactory(type_id='ietf', number='124', date=timezone.now())
url = urlreverse(ietf.stats.views.stats_index)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
def test_document_stats(self):
- r = self.client.get(urlreverse("ietf.stats.views.document_stats"))
- self.assertRedirects(r, urlreverse("ietf.stats.views.stats_index"))
-
+ # Create a meeting as the index page needs to know the current meeting
+ MeetingFactory(type_id='ietf', number='124', date=timezone.now())
+ r = self.client.get(urlreverse(ietf.stats.views.document_stats))
+ self.assertRedirects(r, urlreverse(ietf.stats.views.stats_index))
def test_meeting_stats(self):
- meeting = MeetingFactory(type_id='ietf', number='124')
+ meeting = MeetingFactory(type_id='ietf', number='124', date=timezone.now())
RegistrationFactory.create_batch(5, meeting=meeting, with_ticket={'attendance_type_id': 'onsite'}, attended=True)
RegistrationFactory(meeting=meeting, with_ticket={'attendance_type_id': 'onsite'}, attended=False)
RegistrationFactory.create_batch(4, meeting=meeting, with_ticket={'attendance_type_id': 'remote'}, attended=True)
RegistrationFactory(meeting=meeting, with_ticket={'attendance_type_id': 'remote'}, attended=False)
- r = self.client.get(urlreverse("ietf.stats.views.meeting_stats", kwargs={'meeting_number': '124','stats_type': 'affiliation'}))
+ r = self.client.get(urlreverse(ietf.stats.views.meeting_stats, kwargs={"meeting_number": "124", "stats_type": "affiliation"}))
self.assertEqual(r.status_code, 200)
self.assertContains(r, "Total Registrations by Affiliation (11 in total)")
self.assertContains(r, "In Person Registrations by Affiliation (6 in total)")
- r = self.client.get(urlreverse("ietf.stats.views.meeting_stats", kwargs={'meeting_number': '124','stats_type': 'country'}))
+ r = self.client.get(urlreverse(ietf.stats.views.meeting_stats, kwargs={"meeting_number": "124", "stats_type": "country"}))
self.assertEqual(r.status_code, 200)
self.assertContains(r, "Total Registrations by Country (11 in total)")
self.assertContains(r, "In Person Registrations by Country (6 in total)")
diff --git a/ietf/stats/views.py b/ietf/stats/views.py
index 222b02d33f8..e6ff09fda17 100644
--- a/ietf/stats/views.py
+++ b/ietf/stats/views.py
@@ -34,7 +34,10 @@
def stats_index(request):
- return render(request, "stats/index.html")
+ current_meeting = get_current_ietf_meeting_num()
+ return render(request, "stats/index.html", {
+ "current_meeting": current_meeting
+ })
def generate_query_string(query_dict, overrides):
query_part = ""
diff --git a/ietf/templates/stats/index.html b/ietf/templates/stats/index.html
index b3b7b425591..38c8069507f 100644
--- a/ietf/templates/stats/index.html
+++ b/ietf/templates/stats/index.html
@@ -15,10 +15,13 @@
- Statistics on authorship or per continent meeting are not currently available.
+ Statistics on authorship are not currently available.
{% endblock %}
\ No newline at end of file
From 8a989e0658b0f262b538cc8e60a3ccb5967c6148 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Wed, 25 Mar 2026 09:18:38 +0000
Subject: [PATCH 17/34] Remove unused JS code
---
ietf/templates/stats/meetings_timeline.html | 6 ------
1 file changed, 6 deletions(-)
diff --git a/ietf/templates/stats/meetings_timeline.html b/ietf/templates/stats/meetings_timeline.html
index 657979e1cd6..b66528fa5e5 100644
--- a/ietf/templates/stats/meetings_timeline.html
+++ b/ietf/templates/stats/meetings_timeline.html
@@ -67,9 +67,6 @@
In Person Registrations by {{ stats_type|title }}
{% endblock %}
\ No newline at end of file
From beb2424c8e98eabf4740cdf31630ef4d3d1a24b1 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Wed, 25 Mar 2026 11:34:35 +0000
Subject: [PATCH 21/34] fix a comment
---
ietf/stats/views.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ietf/stats/views.py b/ietf/stats/views.py
index 2353a54dae5..e05215080e4 100644
--- a/ietf/stats/views.py
+++ b/ietf/stats/views.py
@@ -245,7 +245,7 @@ def get_country_data_for_meetings(top_n, attendance_type=None):
return sorted_meetings, datasets
def get_data_for_meetings():
- # Get registration status counts, aggregated by country_code
+ # Get registration status counts, aggregated by ticket types
registrations = Registration.objects.filter(tickets__attendance_type__in=['onsite', 'remote'])
queryset = (
registrations
From 9fb3ab7d729a8d813b40c39fe9d13ec7c0dbb2ba Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Thu, 26 Mar 2026 14:39:21 +0000
Subject: [PATCH 22/34] Add timeline for affiliation
---
ietf/stats/views.py | 237 +++++++++++++++++++++++++++++++++++++++++---
1 file changed, 222 insertions(+), 15 deletions(-)
diff --git a/ietf/stats/views.py b/ietf/stats/views.py
index e05215080e4..7c0dc01e99a 100644
--- a/ietf/stats/views.py
+++ b/ietf/stats/views.py
@@ -33,12 +33,17 @@
def stats_index(request):
+ """Render the statistics index page with the current meeting number as it is required by the meeting menu item."""
current_meeting = get_current_ietf_meeting_num()
return render(request, "stats/index.html", {
"current_meeting": current_meeting
})
def generate_query_string(query_dict, overrides):
+ """
+ Returns:
+ A query string starting with '?' if there are parameters, empty string otherwise.
+ """
query_part = ""
if query_dict or overrides:
@@ -63,9 +68,20 @@ def generate_query_string(query_dict, overrides):
return query_part
def get_choice(request, get_parameter, possible_choices, multiple=False):
- # the statistics are built with links to make navigation faster,
- # so we don't really have a form in most cases, so just use this
- # helper instead to select between the choices
+ """Extract a choice from the request GET parameters.
+
+ Since statistics pages use links for navigation instead of forms,
+ this helper selects between possible choices from the URL parameters.
+
+ Args:
+ request: The HTTP request object.
+ get_parameter: The name of the GET parameter.
+ possible_choices: List of tuples (value, label).
+ multiple: If True, return a list of found values; otherwise return the first found or None.
+
+ Returns:
+ The selected value(s) or None.
+ """
values = request.GET.getlist(get_parameter)
found = [t[0] for t in possible_choices if t[0] in values]
@@ -78,6 +94,15 @@ def get_choice(request, get_parameter, possible_choices, multiple=False):
return None
def add_url_to_choices(choices, url_builder):
+ """Add URLs to a list of choices.
+
+ Args:
+ choices: List of tuples (slug, label).
+ url_builder: Function that takes a slug and returns a URL.
+
+ Returns:
+ List of tuples (slug, label, url).
+ """
return [ (slug, label, url_builder(slug)) for slug, label in choices]
def put_into_bin(value, bin_size):
@@ -96,12 +121,25 @@ def prune_unknown_bin_with_known(bins):
del bins[""]
def count_bins(bins):
+ """Count the total number of unique names across all non-empty bins.
+
+ Returns:
+ The count of unique names.
+ """
return len({ n for b, names in bins.items() if b for n in names })
def add_labeled_top_series_from_bins(chart_data, bins, limit):
- """Take bins on the form (x, label): [name1, name2, ...], figure out
+ """Add top series data to chart_data from bins.
+
+ Take bins on the form (x, label): [name1, name2, ...], figure out
how many there are per label, take the overall top ones and put
- them into sorted series like [(x1, len(names1)), (x2, len(names2)), ...]."""
+ them into sorted series like [(x1, len(names1)), (x2, len(names2)), ...].
+
+ Args:
+ chart_data: List to append series data to.
+ bins: Dictionary with keys (x, label) and values as lists of names.
+ limit: Maximum number of top labels to include.
+ """
aggregated_bins = defaultdict(set)
xs = set()
for (x, label), names in bins.items():
@@ -127,9 +165,11 @@ def add_labeled_top_series_from_bins(chart_data, bins, limit):
})
def document_stats(request, stats_type=None):
+ """Redirect to the stats index page. Deprecated view."""
return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
def known_countries_list(request, stats_type=None, acronym=None):
+ """Render a list of known countries with their aliases."""
countries = CountryName.objects.prefetch_related("countryalias_set")
for c in countries:
# the sorting is a bit of a hack - it puts the ISO code first
@@ -141,21 +181,126 @@ def known_countries_list(request, stats_type=None, acronym=None):
})
def canonicalize_affiliation(affiliation):
- if not affiliation:
+ """Canonicalize an affiliation string by removing common suffixes and standardizing prefixes.
+
+ Args:
+ affiliation: The affiliation string to canonicalize.
+
+ Returns:
+ The canonicalized affiliation string, or None if input is None.
+ """
+ if not affiliation or affiliation.lower() in ('n/a', 'none', 'unspecified'):
return None
for suffix in ('ab', 'ag', 'corp', 'corp.', 'corporation', 'gmbh', 'inc.', 'inc', 'international pte ltd', 'llc', 'ltd', 'ltd.', 'private limited', 'pty ltd', 'pvt ltd'):
- if affiliation.lower().endswith(' ' + suffix):
- affiliation[:-(len(suffix)+1)]
- if affiliation.lower().endswith(',' + suffix):
- affiliation[:-(len(suffix)+1)]
if affiliation.lower().endswith(', ' + suffix):
- affiliation[:-(len(suffix)+2)]
- for prefix in ('akamai','apple', 'cisco', 'futurewei', 'google', 'hpe', 'huawei', 'meta', 'nokia', 'siemens'):
+ affiliation = affiliation[:-(len(suffix)+2)]
+ elif affiliation.lower().endswith(' ' + suffix):
+ affiliation = affiliation[:-(len(suffix)+1)]
+ elif affiliation.lower().endswith(',' + suffix):
+ affiliation = affiliation[:-(len(suffix)+1)]
+ for prefix in ('akamai','apple', 'cisco', 'futurewei', 'google', 'hitachi', 'hpe', 'huawei', 'juniper', 'meta', 'nokia', 'ntt', 'siemens'):
if affiliation.lower().startswith(prefix + ' '):
affiliation = prefix
return affiliation.title()
+def get_affiliation_data_for_meetings(top_n, attendance_type=None):
+ """Get affiliation participation data for meetings timeline chart.
+
+ Args:
+ top_n: Number of top affiliations to include.
+ attendance_type: Optional filter for attendance type (e.g., 'onsite').
+
+ Returns:
+ Tuple of (sorted_meetings, datasets) for Chart.js.
+ """
+ # Get registration status details
+ if attendance_type:
+ registrations = Registration.objects.filter(tickets__attendance_type=attendance_type)
+ else:
+ registrations = Registration.objects.all()
+ registrations = registrations.values('affiliation', 'meeting__number')
+
+ # Count per canonicalized affiliation
+ organization = dict()
+ meetings_set = set()
+ org_totals = defaultdict(int)
+ data_map = defaultdict(dict) # {org: {meeting: count}}
+
+ for reg in registrations:
+ meeting = reg['meeting__number']
+ meetings_set.add(meeting)
+ affiliation = canonicalize_affiliation(reg['affiliation']) or "Unspecified"
+ organization[affiliation] = organization.get(affiliation, 0) + 1
+ org_totals[affiliation] = org_totals.get(affiliation, 0) + 1
+ data_map[affiliation][meeting] = data_map[affiliation].get(meeting, 0) + 1
+
+ # ── Step 2: Sort meetings numerically rather than alphabetically ──
+ sorted_meetings = sorted(meetings_set, key=lambda x: int(x) if x.isdigit() else x)
+
+ # ── Step 3: Get top N countries ──
+ top_orgs = sorted(
+ org_totals.keys(),
+ key=lambda c: org_totals[c],
+ reverse=True
+ )[:top_n]
+ non_top_orgs = org_totals.keys() - top_orgs
+ other_totals = defaultdict(int)
+ for m in sorted_meetings:
+ other_totals[m] = 0
+ for c in non_top_orgs:
+ other_totals[m] += int(data_map[c].get(m, 0))
+
+ # ── Step 4: Build Chart.js datasets ──
+ # Color palette for lines
+ colors = [
+ '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
+ '#FF9F40', '#C9CBCF', '#7BC043', '#F37735', '#00ABA9',
+ '#2B5797', '#E81123', '#00A4EF', '#7FBA00', '#FFB900',
+ '#D83B01', '#B4009E', '#5C2D91', '#008575', '#E3008C',
+ ]
+
+ datasets = []
+ for idx, org in enumerate(top_orgs):
+ color = colors[idx % len(colors)]
+ datasets.append({
+ 'label': org,
+ 'data': [data_map[org].get(m, 0) for m in sorted_meetings],
+ 'borderColor': color,
+ 'fill': False,
+ 'tension': 0.3,
+ 'pointColor': color,
+ 'pointBackgroundColor': color,
+ 'pointRadius': 4,
+ 'pointHoverRadius': 6,
+ 'borderWidth': 2,
+ })
+
+ # -- Step 4.bis handle the other --
+ datasets.append({
+ 'label': 'Other',
+ 'data': [other_totals.get(m, 0) for m in sorted_meetings],
+ 'borderColor': 'black',
+ 'fill': False,
+ 'tension': 0.3,
+ 'pointColor': 'black',
+ 'pointBackgroundColor': 'black',
+ 'pointRadius': 4,
+ 'pointHoverRadius': 6,
+ 'borderWidth': 2,
+ })
+
+ return sorted_meetings, datasets
+
def get_country_data_for_meetings(top_n, attendance_type=None):
+ """Get country participation data for meetings timeline chart.
+
+ Args:
+ top_n: Number of top countries to include.
+ attendance_type: Optional filter for attendance type (e.g., 'onsite').
+
+ Returns:
+ Tuple of (sorted_meetings, datasets) for Chart.js.
+ """
# Get registration status counts, aggregated by country_code
if attendance_type:
registrations = Registration.objects.filter(tickets__attendance_type=attendance_type)
@@ -245,6 +390,11 @@ def get_country_data_for_meetings(top_n, attendance_type=None):
return sorted_meetings, datasets
def get_data_for_meetings():
+ """Get total participation data by attendance type for meetings timeline chart.
+
+ Returns:
+ Tuple of (sorted_meetings, datasets) for Chart.js.
+ """
# Get registration status counts, aggregated by ticket types
registrations = Registration.objects.filter(tickets__attendance_type__in=['onsite', 'remote'])
queryset = (
@@ -299,12 +449,26 @@ def get_data_for_meetings():
return sorted_meetings, datasets
def meetings_timeline(request, stats_type='country', top_n=10):
+ """Render the meetings timeline page with participation statistics over time.
+
+ Args:
+ request: The HTTP request object.
+ stats_type: Type of statistics ('country' or 'total').
+ top_n: Number of top items to show (for country stats).
+
+ Returns:
+ Rendered response for the meetings timeline template.
+ """
if stats_type == 'total':
total_labels, total_data_sets = get_data_for_meetings()
+ elif stats_type == 'affiliation':
+ top_n = 20 # For affiliations we can have more entries, so show more by default
+ total_labels, total_data_sets = get_affiliation_data_for_meetings(top_n)
+ in_person_labels, in_person_data_sets = get_affiliation_data_for_meetings(top_n, attendance_type='onsite')
elif stats_type == 'country':
- total_labels, total_data_sets = get_country_data_for_meetings(10)
- in_person_labels, in_person_data_sets = get_country_data_for_meetings(10, attendance_type='onsite')
+ total_labels, total_data_sets = get_country_data_for_meetings(top_n)
+ in_person_labels, in_person_data_sets = get_country_data_for_meetings(top_n, attendance_type='onsite')
else:
return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
@@ -325,7 +489,7 @@ def meetings_timeline(request, stats_type='country', top_n=10):
# Prepare the list of choice buttons for the template
possible_stats_types = [
-# TODO ("affiliation", "Per affiliation", urlreverse(meetings_timeline, kwargs={'stats_type': 'affiliation'})),
+ ("affiliation", "Per affiliation", urlreverse(meetings_timeline, kwargs={'stats_type': 'affiliation'})),
("country", "Per country", urlreverse(meetings_timeline, kwargs={'stats_type': 'country'})),
("total", "Total", urlreverse(meetings_timeline, kwargs={'stats_type': 'total'})),
]
@@ -351,6 +515,16 @@ def meetings_timeline(request, stats_type='country', top_n=10):
def get_affiliation_data_for_meeting(meeting_number, minimum_required, attendance_type=None):
+ """Get affiliation participation data for a specific meeting.
+
+ Args:
+ meeting_number: The meeting number.
+ minimum_required: Minimum count to include in main data (others go to 'Other').
+ attendance_type: Optional filter for attendance type.
+
+ Returns:
+ Tuple of (labels, data, total) for chart display.
+ """
# Get registration status details
registrations = Registration.objects.filter(meeting__number=meeting_number)
if attendance_type:
@@ -384,6 +558,16 @@ def get_affiliation_data_for_meeting(meeting_number, minimum_required, attendanc
return labels, data, total
def get_data_for_meeting(meeting_number, minimum_required, attendance_type=None):
+ """Get country participation data for a specific meeting.
+
+ Args:
+ meeting_number: The meeting number.
+ minimum_required: Minimum count to include in main data (others go to 'Other').
+ attendance_type: Optional filter for attendance type.
+
+ Returns:
+ Tuple of (labels, data, total) for chart display.
+ """
# Get registration status counts, aggregated by country_code
registration_counts = Registration.objects.filter(meeting__number=meeting_number)
if attendance_type:
@@ -409,6 +593,16 @@ def get_data_for_meeting(meeting_number, minimum_required, attendance_type=None)
return labels, data, total
def meeting_stats(request, meeting_number=None, stats_type='country'):
+ """Render statistics for a specific meeting.
+
+ Args:
+ request: The HTTP request object.
+ meeting_number: The meeting number (defaults to current).
+ stats_type: Type of statistics ('country' or 'affiliation').
+
+ Returns:
+ Rendered response for the meeting stats template.
+ """
current_meeting = get_current_ietf_meeting_num()
if meeting_number is None:
@@ -480,6 +674,19 @@ def meeting_stats(request, meeting_number=None, stats_type='country'):
@login_required
def review_stats(request, stats_type=None, acronym=None):
+ """Render review statistics page with tables and charts for review assignments.
+
+ Shows completion status, results, assignment states, and time series data.
+ Supports both team-level and reviewer-level views with filtering options.
+
+ Args:
+ request: The HTTP request object.
+ stats_type: Type of statistics ('completion', 'results', 'states', 'time').
+ acronym: Team acronym for reviewer-level view (None for team view).
+
+ Returns:
+ Rendered response for the review stats template.
+ """
# This view is a bit complex because we want to show a bunch of
# tables with various filtering options, and both a team overview
# and a reviewers-within-team overview - and a time series chart.
From 582a9b75f0c23bbe1028ed9c2933250fb6c1bedc Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Thu, 26 Mar 2026 15:59:52 +0000
Subject: [PATCH 23/34] Expanding the test coverage to affiliation timeline
---
ietf/stats/tests.py | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/ietf/stats/tests.py b/ietf/stats/tests.py
index 7be59923a1d..1766af34f06 100644
--- a/ietf/stats/tests.py
+++ b/ietf/stats/tests.py
@@ -5,7 +5,9 @@
import calendar
import json
import datetime
+from xml.sax.saxutils import unescape
+from botocore import response
from pyquery import PyQuery
import debug # pyflakes:ignore
@@ -45,8 +47,8 @@ def test_meeting_stats(self):
RegistrationFactory(meeting=meeting124, with_ticket={'attendance_type_id': 'onsite'}, attended=False)
RegistrationFactory.create_batch(14, meeting=meeting124, with_ticket={'attendance_type_id': 'remote'}, attended=True)
RegistrationFactory(meeting=meeting124, with_ticket={'attendance_type_id': 'remote'}, attended=False)
- RegistrationFactory.create_batch(15, meeting=meeting125, with_ticket={'attendance_type_id': 'onsite'}, attended=False)
- RegistrationFactory.create_batch(25, meeting=meeting125, with_ticket={'attendance_type_id': 'onsite'}, attended=False)
+ RegistrationFactory.create_batch(15, meeting=meeting125, affiliation='Test LLC', with_ticket={'attendance_type_id': 'remote'}, attended=False)
+ RegistrationFactory.create_batch(25, meeting=meeting125, affiliation='Example, Ltd', with_ticket={'attendance_type_id': 'onsite'}, attended=False)
# Test the meeting specific statitistics per affiliation and per country
r = self.client.get(urlreverse(ietf.stats.views.meeting_stats, kwargs={"meeting_number": "124", "stats_type": "affiliation"}))
self.assertEqual(r.status_code, 200)
@@ -66,7 +68,14 @@ def test_meeting_stats(self):
self.assertContains(r, "/stats/meeting/124/country")
self.assertContains(r, "/stats/meeting/125/country")
self.assertContains(r, "This page provides a timeline of meeting registrations by country")
- # Test the meetings timeline (globally)
+ # Test the meetings timeline per affiliation
+ r = self.client.get(urlreverse(ietf.stats.views.meetings_timeline, kwargs={"stats_type": "affiliation"}))
+ self.assertEqual(r.status_code, 200)
+ self.assertContains(r, "/stats/meeting/124/affiliation")
+ self.assertContains(r, "/stats/meeting/125/affiliation")
+ self.assertContains(r, "This page provides a timeline of meeting registrations by affiliation")
+ self.assertContains(r, '\\u0022Example\\u0022, \\u0022data\\u0022: [0, 25]')
+ # Test the global meetings timeline
r = self.client.get(urlreverse(ietf.stats.views.meetings_timeline, kwargs={"stats_type": "total"}))
self.assertEqual(r.status_code, 200)
self.assertContains(r, "/stats/meeting/124/country")
From 46c090157cdfd30072e2dcda8f7b18fc6484ca8b Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Thu, 26 Mar 2026 16:58:09 +0000
Subject: [PATCH 24/34] Remove unused botocore (unsure how it was added though)
---
ietf/stats/tests.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/ietf/stats/tests.py b/ietf/stats/tests.py
index 1766af34f06..1881a87fdbb 100644
--- a/ietf/stats/tests.py
+++ b/ietf/stats/tests.py
@@ -7,7 +7,6 @@
import datetime
from xml.sax.saxutils import unescape
-from botocore import response
from pyquery import PyQuery
import debug # pyflakes:ignore
From 76f9b6a8716819f76485f400e546bc8f9644d8d9 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Thu, 26 Mar 2026 20:49:43 +0000
Subject: [PATCH 25/34] Remove unused package
---
ietf/stats/tests.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/ietf/stats/tests.py b/ietf/stats/tests.py
index 1881a87fdbb..6dd33d3d3bb 100644
--- a/ietf/stats/tests.py
+++ b/ietf/stats/tests.py
@@ -5,7 +5,6 @@
import calendar
import json
import datetime
-from xml.sax.saxutils import unescape
from pyquery import PyQuery
From eeb28c6f99001d28d10f852caf7bec2bf50bd0c3 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Fri, 27 Mar 2026 12:06:25 +0000
Subject: [PATCH 26/34] Code clean-up, add pan & zoom on timelines
---
ietf/stats/views.py | 88 +++------------------
ietf/templates/stats/meetings_timeline.html | 40 ++++++++--
2 files changed, 47 insertions(+), 81 deletions(-)
diff --git a/ietf/stats/views.py b/ietf/stats/views.py
index 7c0dc01e99a..512764ec0e1 100644
--- a/ietf/stats/views.py
+++ b/ietf/stats/views.py
@@ -31,6 +31,13 @@
from ietf.utils.timezone import date_today, DEADLINE_TZINFO
from ietf.meeting.helpers import get_current_ietf_meeting_num, get_ietf_meeting
+# Color palette for lines
+colors = [
+ '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
+ '#FF9F40', '#C9CBCF', '#7BC043', '#F37735', '#00ABA9',
+ '#2B5797', '#E81123', '#00A4EF', '#7FBA00', '#FFB900',
+ '#D83B01', '#B4009E', '#5C2D91', '#008575', '#E3008C',
+]
def stats_index(request):
"""Render the statistics index page with the current meeting number as it is required by the meeting menu item."""
@@ -105,66 +112,11 @@ def add_url_to_choices(choices, url_builder):
"""
return [ (slug, label, url_builder(slug)) for slug, label in choices]
-def put_into_bin(value, bin_size):
- if value is None:
- return (0, '')
-
- v = (value // bin_size) * bin_size
- return (v, "{} - {}".format(v, v + bin_size - 1))
-
-def prune_unknown_bin_with_known(bins):
- # remove from the unknown bin all authors within the
- # named/known bins
- all_known = { n for b, names in bins.items() if b for n in names }
- bins[""] = [name for name in bins[""] if name not in all_known]
- if not bins[""]:
- del bins[""]
-
-def count_bins(bins):
- """Count the total number of unique names across all non-empty bins.
-
- Returns:
- The count of unique names.
- """
- return len({ n for b, names in bins.items() if b for n in names })
-
-def add_labeled_top_series_from_bins(chart_data, bins, limit):
- """Add top series data to chart_data from bins.
-
- Take bins on the form (x, label): [name1, name2, ...], figure out
- how many there are per label, take the overall top ones and put
- them into sorted series like [(x1, len(names1)), (x2, len(names2)), ...].
-
- Args:
- chart_data: List to append series data to.
- bins: Dictionary with keys (x, label) and values as lists of names.
- limit: Maximum number of top labels to include.
- """
- aggregated_bins = defaultdict(set)
- xs = set()
- for (x, label), names in bins.items():
- xs.add(x)
- aggregated_bins[label].update(names)
-
- xs = list(sorted(xs))
-
- sorted_bins = sorted(aggregated_bins.items(), key=lambda t: len(t[1]), reverse=True)
- top = [ label for label, names in list(sorted_bins)[:limit]]
-
- for label in top:
- series_data = []
-
- for x in xs:
- names = bins.get((x, label), set())
-
- series_data.append((x, len(names)))
-
- chart_data.append({
- "data": series_data,
- "name": label
- })
-
def document_stats(request, stats_type=None):
+ # timeline per year, or per specific year: streams, affiliation, rfc vs I-D
+ # could also be time between individual/WG I-D to rfc publication/IESG ballot
+ # DISCUSS resolution time
+ # Humm also split by authors (affiliation) / documents (the rest) probably
"""Redirect to the stats index page. Deprecated view."""
return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
@@ -251,13 +203,6 @@ def get_affiliation_data_for_meetings(top_n, attendance_type=None):
other_totals[m] += int(data_map[c].get(m, 0))
# ── Step 4: Build Chart.js datasets ──
- # Color palette for lines
- colors = [
- '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
- '#FF9F40', '#C9CBCF', '#7BC043', '#F37735', '#00ABA9',
- '#2B5797', '#E81123', '#00A4EF', '#7FBA00', '#FFB900',
- '#D83B01', '#B4009E', '#5C2D91', '#008575', '#E3008C',
- ]
datasets = []
for idx, org in enumerate(top_orgs):
@@ -349,13 +294,6 @@ def get_country_data_for_meetings(top_n, attendance_type=None):
other_totals[m] += int(data_map[c].get(m, 0))
# ── Step 4: Build Chart.js datasets ──
- # Color palette for lines
- colors = [
- '#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
- '#FF9F40', '#C9CBCF', '#7BC043', '#F37735', '#00ABA9',
- '#2B5797', '#E81123', '#00A4EF', '#7FBA00', '#FFB900',
- '#D83B01', '#B4009E', '#5C2D91', '#008575', '#E3008C',
- ]
datasets = []
for idx, country in enumerate(top_countries):
@@ -513,7 +451,6 @@ def meetings_timeline(request, stats_type='country', top_n=10):
"in_person_chart_data": in_person_chart_data,
})
-
def get_affiliation_data_for_meeting(meeting_number, minimum_required, attendance_type=None):
"""Get affiliation participation data for a specific meeting.
@@ -525,7 +462,7 @@ def get_affiliation_data_for_meeting(meeting_number, minimum_required, attendanc
Returns:
Tuple of (labels, data, total) for chart display.
"""
- # Get registration status details
+ # Get registration status details
registrations = Registration.objects.filter(meeting__number=meeting_number)
if attendance_type:
registrations = registrations.filter(tickets__attendance_type=attendance_type)
@@ -621,7 +558,6 @@ def meeting_stats(request, meeting_number=None, stats_type='country'):
else:
return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
-
# Serialize to JSON for safe injection into the template
total_chart_data = json.dumps({
'labels': total_labels,
diff --git a/ietf/templates/stats/meetings_timeline.html b/ietf/templates/stats/meetings_timeline.html
index 27c4dc1688e..a0303e0c313 100644
--- a/ietf/templates/stats/meetings_timeline.html
+++ b/ietf/templates/stats/meetings_timeline.html
@@ -5,7 +5,8 @@
{% block pagehead %}
-
+
+
{% endblock %}
{% block content %}
{% origin %}
@@ -42,6 +43,8 @@
{% else %}
This page provides a timeline of meeting registrations by {{ stats_type }} with a limit of {{ top_n }} categories.
{% endif %}
+ Panning can be done via the mouse or with a finger. Zooming is done via the mouse wheel or via a pinch gesture. Press ESC
+ or click to reset panning/zooming.
@@ -80,7 +83,7 @@
In Person Registrations by {{ stats_type|title }}
function displayChart(id, data) {
const ctx = document.getElementById(id).getContext('2d');
- new Chart(ctx, {
+ return new Chart(ctx, {
type: 'line', // Change to 'doughnut' for a donut chart
data: data,
options: {
@@ -118,13 +121,40 @@
In Person Registrations by {{ stats_type|title }}
}
}
},
+ zoom: {
+ zoom: {
+ wheel: { enabled: true }, // scroll to zoom
+ pinch: { enabled: true }, // pinch on mobile
+ drag: { enabled: true }, // drag to select range
+ mode: 'xy', // zoom X-axis and Y-axis
+ },
+ pan: {
+ enabled: true,
+ mode: 'xy', // pan X-axis and Y-axis
+ },
+ },
}
}
});
}
- displayChart('totalRegistrationChart', totalChartData) ;
- if (inPersonChartData != null)
- displayChart('inPersonRegistrationChart', inPersonChartData) ;
+ const totalChart = displayChart('totalRegistrationChart', totalChartData) ;
+ if (inPersonChartData != null) {
+ inPersonChart = displayChart('inPersonRegistrationChart', inPersonChartData) ;
+ }
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') {
+ totalChart.resetZoom();
+ if (inPersonChart != null) {
+ inPersonChart.resetZoom();
+ }
+ }
+ });
+ document.getElementById('resetButton').addEventListener('click', () => {
+ totalChart.resetZoom();
+ if (inPersonChart != null) {
+ inPersonChart.resetZoom();
+ }
+ });
{% endblock %}
\ No newline at end of file
From 1d1c209dd27a0e1576722f0fe907f4abe2a33980 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Fri, 27 Mar 2026 12:46:50 +0000
Subject: [PATCH 27/34] Fix button type
---
ietf/templates/stats/meetings_timeline.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ietf/templates/stats/meetings_timeline.html b/ietf/templates/stats/meetings_timeline.html
index a0303e0c313..e89eae51968 100644
--- a/ietf/templates/stats/meetings_timeline.html
+++ b/ietf/templates/stats/meetings_timeline.html
@@ -44,7 +44,7 @@
This page provides a timeline of meeting registrations by {{ stats_type }} with a limit of {{ top_n }} categories.
{% endif %}
Panning can be done via the mouse or with a finger. Zooming is done via the mouse wheel or via a pinch gesture. Press ESC
- or click to reset panning/zooming.
+ or click to reset panning/zooming.