Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
ef05fce
Draft for meeting registrations
evyncke Mar 14, 2026
a0fd930
Add totals, + nicer JS code
evyncke Mar 14, 2026
7dfa8d4
Coherent URL parameters
evyncke Mar 14, 2026
25c79a5
Handle error case when per-continent stats is requested
evyncke Mar 14, 2026
557291f
Remove redundant code
evyncke Mar 14, 2026
ebc4bdc
Dynamic get the current IETF meeting
evyncke Mar 14, 2026
2e55643
Display %-age when hovering
evyncke Mar 14, 2026
ac4f28e
Add test for meeting statistics
evyncke Mar 15, 2026
62a6784
Add test
evyncke Mar 16, 2026
14780b9
Add statistics per affiliation
evyncke Mar 16, 2026
90aa3ac
More code coverage for test
evyncke Mar 16, 2026
dd64825
Merge branch 'main' into feat-statistics
evyncke Mar 16, 2026
b159b48
Nicer canonical affiliation
evyncke Mar 16, 2026
b0d2842
Allow navigation by buttons
evyncke Mar 16, 2026
fa4354f
Merge branch 'main' into feat-statistics
evyncke Mar 17, 2026
e41a545
Also add participants count in the legend
evyncke Mar 17, 2026
19233d4
Add timeline over meetings (total and per country)
evyncke Mar 24, 2026
c294660
Merge branch 'main' into feat-statistics
evyncke Mar 24, 2026
94e00ac
Default index refers to current meeting stats by number
evyncke Mar 25, 2026
8a989e0
Remove unused JS code
evyncke Mar 25, 2026
0067933
Add test coverage for timeline statistics
evyncke Mar 25, 2026
f803986
No need to import test coverage
evyncke Mar 25, 2026
c8adc61
Use stacked lines of onsite/remote when displaying the total timeline
evyncke Mar 25, 2026
beb2424
fix a comment
evyncke Mar 25, 2026
9fb3ab7
Add timeline for affiliation
evyncke Mar 26, 2026
582a9b7
Expanding the test coverage to affiliation timeline
evyncke Mar 26, 2026
46c0901
Remove unused botocore (unsure how it was added though)
evyncke Mar 26, 2026
76f9b6a
Remove unused package
evyncke Mar 26, 2026
eeb28c6
Code clean-up, add pan & zoom on timelines
evyncke Mar 27, 2026
1d1c209
Fix button type
evyncke Mar 27, 2026
05e4402
Merge branch 'main' into fork/evyncke/feat-statistics
jennifer-richards Apr 24, 2026
d9c240f
refactor: avoid inline JS; safer JSON handling
jennifer-richards Apr 27, 2026
bfb4d51
chore: lint
jennifer-richards Apr 27, 2026
6d837b6
feat: cache timeline stats
jennifer-richards Apr 27, 2026
a814a05
chore: timeout->settings + drop stale setting
jennifer-richards Apr 27, 2026
afc9e81
refactor: wait for DOMContentLoaded + restyle
jennifer-richards Apr 27, 2026
0f19195
fix: fix null checks
jennifer-richards Apr 27, 2026
4fd73cf
Merge branch 'ietf-tools:main' into feat-statistics
evyncke Apr 28, 2026
f368de9
Merge pull request #1 from jennifer-richards/externalize-stats-scripts
evyncke Apr 28, 2026
138e936
test: update test_meeting_stats()
jennifer-richards Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ietf/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
AGENDA_CACHE_TIMEOUT_DEFAULT = 8 * 24 * 60 * 60 # 8 days
AGENDA_CACHE_TIMEOUT_CURRENT_MEETING = 6 * 60 # 6 minutes


WSGI_APPLICATION = "ietf.wsgi.application"

AUTHENTICATION_BACKENDS = ( 'ietf.ietfauth.backends.CaseInsensitiveModelBackend', )
Expand Down Expand Up @@ -1270,7 +1271,7 @@ def skip_unreadable_post(record):
except ImportError:
pass

STATS_NAMES_LIMIT = 25
STATS_TIMELINE_CACHE_TIMEOUT = 86400

UTILS_MEETING_CONFERENCE_DOMAINS = ['webex.com', 'zoom.us', 'jitsi.org', 'meetecho.com', 'gather.town', ]
UTILS_TEST_RANDOM_STATE_FILE = '.factoryboy_random_state'
Expand Down
57 changes: 57 additions & 0 deletions ietf/static/js/meeting_stats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright The IETF Trust 2026, All Rights Reserved
document.addEventListener('DOMContentLoaded', () => {
// Need to use autocolors plug-in else all slices are gray...
const autocolors = window['chartjs-plugin-autocolors']
Chart.register(autocolors)
// ── Safely parse JSON data injected from Django view ──
const totalChartData = JSON.parse(document.getElementById('total-chart-data').textContent)
const inPersonChartData = JSON.parse(document.getElementById('in-person-chart-data').textContent)

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',
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: {
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}%)`
}
}
}
}
}
})
}

displayChart('totalRegistrationChart', totalChartData)
displayChart('inPersonRegistrationChart', inPersonChartData)
})
84 changes: 84 additions & 0 deletions ietf/static/js/meeting_timeline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright The IETF Trust 2026, All Rights Reserved
document.addEventListener('DOMContentLoaded', () => {
// ── Safely parse JSON data injected from Django view ──
const totalChartData = JSON.parse(document.getElementById('total-chart-data').textContent)
const inPersonChartData = JSON.parse(document.getElementById('in-person-chart-data').textContent)
const statsType = JSON.parse(document.getElementById('stats-type-data').textContent)
const stackedLines = statsType === 'total'

function displayChart (id, data) {
const ctx = document.getElementById(id).getContext('2d')
return new Chart(ctx, {
type: 'line', // Change to 'doughnut' for a donut chart
data: data,
options: {
responsive: true,
scales: {
y: {
stacked: stackedLines,
},
x: {
title: {
display: true,
text: 'IETF Meeting Number',
},
},
},
plugins: {
legend: {
position: 'bottom',
labels: {
usePointStyle: true,
padding: 15,
font: { size: 12 },
},
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
titleFont: { size: 14 },
bodyFont: { size: 13 },
callbacks: {
title: function (items) {
return `IETF Meeting ${items[0].label}`
},
label: function (context) {
return ` ${context.dataset.label}: ${context.parsed.y} participants`
}
}
},
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
},
},
}
}
})
}

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()
}
})
})
63 changes: 57 additions & 6 deletions ietf/stats/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

import calendar
import json
import datetime

from pyquery import PyQuery

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
Expand All @@ -18,24 +20,73 @@
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


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):
r = self.client.get(urlreverse("ietf.stats.views.meeting_stats"))
self.assertRedirects(r, urlreverse("ietf.stats.views.stats_index"))

meeting124 = MeetingFactory(type_id='ietf', number='124', date=timezone.now())
meeting125 = MeetingFactory(type_id='ietf', number='125', date=timezone.now() + datetime.timedelta(days=120))
RegistrationFactory.create_batch(15, meeting=meeting124, with_ticket={'attendance_type_id': 'onsite'}, attended=True)
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, 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)
self.assertContains(r, "Total Registrations by Affiliation (31 in total)")
self.assertContains(r, "In Person Registrations by Affiliation (16 in total)")
self.assertContains(r, "/stats/meeting/124/affiliation")
self.assertContains(r, "/stats/meeting/125/affiliation")
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 (31 in total)")
self.assertContains(r, "In Person Registrations by Country (16 in total)")
self.assertContains(r, "/stats/meeting/124/country")
self.assertContains(r, "/stats/meeting/125/country")
# Test the meetings timeline per country
r = self.client.get(urlreverse(ietf.stats.views.meetings_timeline, kwargs={"stats_type": "country"}))
self.assertEqual(r.status_code, 200)
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 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")
# Extract the JSON embedded in the response
pq = PyQuery(r.content)
in_person_data = json.loads(pq.find("script#in-person-chart-data").text())
self.assertTrue(
any(
ds["label"] == "Example" and ds["data"] == [0, 25]
for ds in in_person_data["datasets"]
)
)
# 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")
self.assertContains(r, "/stats/meeting/125/country")
self.assertContains(r, "This page provides a timeline of meeting registrations.")

def test_known_country_list(self):
# check redirect
Expand Down
5 changes: 3 additions & 2 deletions ietf/stats/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
url(r"^$", views.stats_index),
url(r"^document/(?:(?P<stats_type>authors|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<num>\d+)/(?P<stats_type>country|continent)/$", views.meeting_stats),
url(r"^meeting/(?:(?P<stats_type>overview|country|continent)/)?$", views.meeting_stats),
url(r"^meeting/$", views.meetings_timeline),
url(r"^meeting/(?P<meeting_number>\d+)/(?P<stats_type>affiliation|country)/$", views.meeting_stats),
url(r"^meeting/(?:(?P<stats_type>affiliation|country|total)/)?$", views.meetings_timeline),
url(r"^review/(?:(?P<stats_type>completion|results|states|time)/)?(?:%(acronym)s/)?$" % settings.URL_REGEXPS, views.review_stats),
]
Loading
Loading