From ef05fce1439631b953fa40529d0ef21bddd50125 Mon Sep 17 00:00:00 2001 From: Eric Vyncke Date: Sat, 14 Mar 2026 06:33:47 +0000 Subject: [PATCH 01/34] Draft for meeting registrations --- ietf/stats/views.py | 67 +++++++++++++++++- ietf/templates/base/menu.html | 9 ++- ietf/templates/stats/meeting_stats.html | 90 +++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 ietf/templates/stats/meeting_stats.html diff --git a/ietf/stats/views.py b/ietf/stats/views.py index 504d84e86d6..bc43859e986 100644 --- a/ietf/stats/views.py +++ b/ietf/stats/views.py @@ -6,6 +6,7 @@ import datetime import itertools import json +from coverage import annotate import dateutil.relativedelta from collections import defaultdict @@ -13,7 +14,7 @@ from django.http import HttpResponseRedirect from django.shortcuts import render from django.urls import reverse as urlreverse - +from django.db.models import Count import debug # pyflakes:ignore @@ -28,6 +29,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.models import Registration def stats_index(request): @@ -136,8 +138,67 @@ def known_countries_list(request, stats_type=None, acronym=None): "countries": countries, }) -def meeting_stats(request, num=None, stats_type=None): - return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index")) +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) + if attendance_type: + 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 + for item in registration_counts: + if item['count'] > minimum_required: + labels.append(item['country_code']) + data.append(item['count']) + else: + others_count += item['count'] + + if others_count > 0: + labels.append('Other') + data.append(others_count) + + return labels, data + +def meeting_stats(request, meeting_number=None, stats_type=None): + minimum_required = 10 + + 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') + + # Serialize to JSON for safe injection into the template + total_chart_data = json.dumps({ + 'labels': total_labels, + 'datasets': [{ + 'label': 'TotalRegistrations by Country', + 'data': total_data, + 'borderColor': '#ffffff', + 'borderWidth': 2, + }] + }) + in_person_chart_data = json.dumps({ + 'labels': in_person_labels, + 'datasets': [{ + 'label': 'In Person Registrations by Country', + 'data': in_person_data, + 'borderColor': '#ffffff', + 'borderWidth': 2, + }] + }) + return render(request, "stats/meeting_stats.html", { + "meeting_number": meeting_number, + "minimum_required": minimum_required, + "total_chart_data": total_chart_data, + "in_person_chart_data": in_person_chart_data + }) @login_required diff --git a/ietf/templates/base/menu.html b/ietf/templates/base/menu.html index 8ff6e952daf..2bebd6f08af 100644 --- a/ietf/templates/base/menu.html +++ b/ietf/templates/base/menu.html @@ -428,12 +428,11 @@ Downref registry -
  • - +
  • + Statistics -

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

    From d9c240f1c601bd4622aa30be5bc18b85e7ba25f2 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 27 Apr 2026 11:10:24 -0300 Subject: [PATCH 28/34] refactor: avoid inline JS; safer JSON handling --- ietf/static/js/meeting_stats.js | 54 ++++++++++++ ietf/static/js/meeting_timeline.js | 81 ++++++++++++++++++ ietf/stats/views.py | 20 ++--- ietf/templates/stats/meeting_stats.html | 62 +------------- ietf/templates/stats/meetings_timeline.html | 94 ++------------------- package.json | 2 + 6 files changed, 156 insertions(+), 157 deletions(-) create mode 100644 ietf/static/js/meeting_stats.js create mode 100644 ietf/static/js/meeting_timeline.js diff --git a/ietf/static/js/meeting_stats.js b/ietf/static/js/meeting_stats.js new file mode 100644 index 00000000000..2c26c7e7bfe --- /dev/null +++ b/ietf/static/js/meeting_stats.js @@ -0,0 +1,54 @@ +// 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) ; diff --git a/ietf/static/js/meeting_timeline.js b/ietf/static/js/meeting_timeline.js new file mode 100644 index 00000000000..80d26d2621c --- /dev/null +++ b/ietf/static/js/meeting_timeline.js @@ -0,0 +1,81 @@ +// ── 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(); + } + }); diff --git a/ietf/stats/views.py b/ietf/stats/views.py index 512764ec0e1..f2d689db3ef 100644 --- a/ietf/stats/views.py +++ b/ietf/stats/views.py @@ -410,20 +410,19 @@ def meetings_timeline(request, stats_type='country', top_n=10): else: return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index")) - # Serialize to JSON for safe injection into the template - total_chart_data = json.dumps({ + total_chart_data = { 'labels': total_labels, 'datasets': total_data_sets, - }) + } # On per country/affiliation have a separate graph for inperson if stats_type == 'total': - in_person_chart_data = json.dumps(None) + in_person_chart_data = {} else: - in_person_chart_data = json.dumps({ + in_person_chart_data = { 'labels': in_person_labels, 'datasets': in_person_data_sets, - }) + } # Prepare the list of choice buttons for the template possible_stats_types = [ @@ -558,8 +557,7 @@ 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({ + total_chart_data = { 'labels': total_labels, 'datasets': [{ 'label': 'Total Registrations by ' + stats_type, @@ -567,8 +565,8 @@ def meeting_stats(request, meeting_number=None, stats_type='country'): 'borderColor': '#ffffff', 'borderWidth': 2, }] - }) - in_person_chart_data = json.dumps({ + } + in_person_chart_data = { 'labels': in_person_labels, 'datasets': [{ 'label': 'In Person Registrations by ' + stats_type, @@ -576,7 +574,7 @@ def meeting_stats(request, meeting_number=None, stats_type='country'): 'borderColor': '#ffffff', 'borderWidth': 2, }] - }) + } # Prepare the list of choice buttons for the template possible_stats_types = [ diff --git a/ietf/templates/stats/meeting_stats.html b/ietf/templates/stats/meeting_stats.html index 70ce363f7ab..b32f5a4046b 100644 --- a/ietf/templates/stats/meeting_stats.html +++ b/ietf/templates/stats/meeting_stats.html @@ -2,10 +2,13 @@ {% load origin %} {% origin %} {% load ietf_filters static django_bootstrap5 %} -{% block pagehead %} +{% block js %} + {{ total_chart_data|json_script:"total-chart-data" }} + {{ in_person_chart_data|json_script:"in-person-chart-data" }} + {% endblock %} {% block content %} {% origin %} @@ -55,61 +58,4 @@

    In Person Registrations by {{ stats_type|title }} ({{ in_person_total}} in t

    - - {% endblock %} \ No newline at end of file diff --git a/ietf/templates/stats/meetings_timeline.html b/ietf/templates/stats/meetings_timeline.html index e89eae51968..65fb4e09c04 100644 --- a/ietf/templates/stats/meetings_timeline.html +++ b/ietf/templates/stats/meetings_timeline.html @@ -2,11 +2,15 @@ {% load origin %} {% origin %} {% load ietf_filters static django_bootstrap5 %} -{% block pagehead %} +{% block js %} + {{ total_chart_data|json_script:"total-chart-data" }} + {{ in_person_chart_data|json_script:"in-person-chart-data" }} + {{ stats_type|json_script:"stats-type-data" }} + {% endblock %} {% block content %} {% origin %} @@ -15,6 +19,7 @@

    Statistics for IETF Meeting Registrations {% endblock %}

    +
    @@ -70,91 +75,4 @@

    In Person Registrations by {{ stats_type|title }}

    {% endif %}
    - - {% endblock %} \ No newline at end of file diff --git a/package.json b/package.json index 57642d7860f..6f61aaba29b 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,8 @@ "ietf/static/js/manage-community-list.js", "ietf/static/js/manage-review-requests.js", "ietf/static/js/meeting-interim-request.js", + "ietf/static/js/meeting_stats.js", + "ietf/static/js/meeting_timeline.js", "ietf/static/js/moment.js", "ietf/static/js/navbar-doc-search.js", "ietf/static/js/password_strength.js", From bfb4d513c6d86284cc98c93cb441e80458659707 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 27 Apr 2026 11:20:37 -0300 Subject: [PATCH 29/34] chore: lint --- ietf/stats/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ietf/stats/views.py b/ietf/stats/views.py index f2d689db3ef..08643122b3e 100644 --- a/ietf/stats/views.py +++ b/ietf/stats/views.py @@ -400,6 +400,8 @@ 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 = ([], []) 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) From 6d837b68e43666e91905f93d77cdf53a3216b0c3 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 27 Apr 2026 12:19:13 -0300 Subject: [PATCH 30/34] feat: cache timeline stats Pins the top_n parameter, which had not been plumbed to be dynamically adjustable. --- ietf/stats/views.py | 399 +++++++++++++++++++++++--------------------- 1 file changed, 208 insertions(+), 191 deletions(-) diff --git a/ietf/stats/views.py b/ietf/stats/views.py index 08643122b3e..b9cd5c10a27 100644 --- a/ietf/stats/views.py +++ b/ietf/stats/views.py @@ -10,6 +10,7 @@ from collections import defaultdict from django.contrib.auth.decorators import login_required +from django.core.cache import cache from django.http import HttpResponseRedirect from django.shortcuts import render from django.urls import reverse as urlreverse @@ -155,175 +156,186 @@ def canonicalize_affiliation(affiliation): affiliation = prefix return affiliation.title() -def get_affiliation_data_for_meetings(top_n, attendance_type=None): +def get_affiliation_data_for_meetings(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 ── - - datasets = [] - for idx, org in enumerate(top_orgs): - color = colors[idx % len(colors)] + cache_key = f'stats:get_affiliation_data_for_meetings:{attendance_type}' + cache_timeout = 86400 + sorted_meetings, datasets = cache.get(cache_key, (None, None)) + if (sorted_meetings, datasets) == (None, None): + top_n = 20 # could be a parameter, but would need to adjust cache handling + + # 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 ── + + 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': org, - 'data': [data_map[org].get(m, 0) for m in sorted_meetings], - 'borderColor': color, + 'label': 'Other', + 'data': [other_totals.get(m, 0) for m in sorted_meetings], + 'borderColor': 'black', 'fill': False, 'tension': 0.3, - 'pointColor': color, - 'pointBackgroundColor': color, + 'pointColor': 'black', + 'pointBackgroundColor': 'black', '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, - }) + cache.set(cache_key, (sorted_meetings, datasets), cache_timeout) return sorted_meetings, datasets -def get_country_data_for_meetings(top_n, attendance_type=None): +def get_country_data_for_meetings(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) - else: - registrations = Registration.objects.all() - queryset = ( - registrations - .values( - 'meeting__number', # e.g. "118", "119", "120" - 'country_code' # country code of the participant + cache_key = f'stats:get_country_data_for_meetings:{attendance_type}' + cache_timeout = 86400 + sorted_meetings, datasets = cache.get(cache_key, (None, None)) + if (sorted_meetings, datasets) == (None, None): + top_n = 10 # could be a parameter, but would need to adjust cache handling + # 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" + 'country_code' # country code of the participant + ) + .annotate(participant_count=Count('id')) + .order_by('meeting__number') # chronological order ) - .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 ── - - datasets = [] - for idx, country in enumerate(top_countries): - color = colors[idx % len(colors)] + + # ── 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 ── + + 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': country, - 'data': [data_map[country].get(m, 0) for m in sorted_meetings], - 'borderColor': color, + 'label': 'Other', + 'data': [other_totals.get(m, 0) for m in sorted_meetings], + 'borderColor': 'black', 'fill': False, 'tension': 0.3, - 'pointColor': color, - 'pointBackgroundColor': color, + 'pointColor': 'black', + 'pointBackgroundColor': 'black', '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, - }) + cache.set(cache_key, (sorted_meetings, datasets), cache_timeout) return sorted_meetings, datasets @@ -333,60 +345,64 @@ def get_data_for_meetings(): 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 = ( - registrations - .values( - 'meeting__number', # e.g. "118", "119", "120" - 'tickets__attendance_type' + cache_key = "stats:get_data_for_meetings" + cache_timeout = 86400 + sorted_meetings, datasets = cache.get(cache_key, (None, None)) + if (sorted_meetings, datasets) == (None, None): + # Get registration status counts, aggregated by ticket types + registrations = Registration.objects.filter(tickets__attendance_type__in=['onsite', 'remote']) + queryset = ( + registrations + .values( + 'meeting__number', # e.g. "118", "119", "120" + 'tickets__attendance_type' + ) + .annotate(participant_count=Count('id')) + .order_by('meeting__number') # chronological order ) - .annotate(participant_count=Count('id')) - .order_by('meeting__number') # chronological order - ) - -# ── Step 1: Collect all meetings and tickets totals ── - meetings_set = set() - tickets_totals = defaultdict(int) - data_map = defaultdict(dict) # {ticket: {meeting: count}} - - for row in queryset: - meeting = row['meeting__number'] - ticket = row['tickets__attendance_type'] - count = row['participant_count'] - - meetings_set.add(meeting) - tickets_totals[ticket] += count - data_map[ticket][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) - ticket_types = tickets_totals.keys() - # ── Step 4: Build Chart.js datasets ── - # Color palette for lines - colors = [ '#FF6384', '#36A2EB'] - - datasets = [] - for idx, ticket_type in enumerate(ticket_types): - color = colors[idx % len(colors)] - datasets.append({ - 'label': ticket_type, - 'data': [data_map[ticket_type].get(m, 0) for m in sorted_meetings], - 'borderColor': color, - 'backgroundColor': color + '99', # 60% opacity fill - 'fill': True, - 'tension': 0.0, - 'pointColor': color, - 'pointBackgroundColor': color, - 'pointRadius': 4, - 'pointHoverRadius': 6, - 'borderWidth': 2, - }) - + # ── Step 1: Collect all meetings and tickets totals ── + meetings_set = set() + tickets_totals = defaultdict(int) + data_map = defaultdict(dict) # {ticket: {meeting: count}} + + for row in queryset: + meeting = row['meeting__number'] + ticket = row['tickets__attendance_type'] + count = row['participant_count'] + + meetings_set.add(meeting) + tickets_totals[ticket] += count + data_map[ticket][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) + ticket_types = tickets_totals.keys() + + # ── Step 4: Build Chart.js datasets ── + # Color palette for lines + colors = [ '#FF6384', '#36A2EB'] + + datasets = [] + for idx, ticket_type in enumerate(ticket_types): + color = colors[idx % len(colors)] + datasets.append({ + 'label': ticket_type, + 'data': [data_map[ticket_type].get(m, 0) for m in sorted_meetings], + 'borderColor': color, + 'backgroundColor': color + '99', # 60% opacity fill + 'fill': True, + 'tension': 0.0, + 'pointColor': color, + 'pointBackgroundColor': color, + 'pointRadius': 4, + 'pointHoverRadius': 6, + 'borderWidth': 2, + }) + cache.set(cache_key, (sorted_meetings, datasets), cache_timeout) return sorted_meetings, datasets -def meetings_timeline(request, stats_type='country', top_n=10): +def meetings_timeline(request, stats_type='country'): """Render the meetings timeline page with participation statistics over time. Args: @@ -397,18 +413,19 @@ def meetings_timeline(request, stats_type='country', top_n=10): Returns: Rendered response for the meetings timeline template. """ - if stats_type == 'total': total_labels, total_data_sets = get_data_for_meetings() in_person_labels = ([], []) in_person_data_sets = ([], []) + top_n = len(total_data_sets) - 1 # subtract one because we don't count "other" 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') + total_labels, total_data_sets = get_affiliation_data_for_meetings() + in_person_labels, in_person_data_sets = get_affiliation_data_for_meetings(attendance_type='onsite') + top_n = len(total_data_sets) - 1 # subtract one because we don't count "other" elif stats_type == 'country': - 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') + total_labels, total_data_sets = get_country_data_for_meetings() + in_person_labels, in_person_data_sets = get_country_data_for_meetings(attendance_type='onsite') + top_n = len(total_data_sets) - 1 # subtract one because we don't count "other" else: return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index")) From a814a05ea9de9f3e3b929b7e17f4835678195d3b Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 27 Apr 2026 12:30:20 -0300 Subject: [PATCH 31/34] chore: timeout->settings + drop stale setting STATS_NAMES_LIMIT does not appear anywhere else in the codebase --- ietf/settings.py | 3 ++- ietf/stats/views.py | 22 ++++++++++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/ietf/settings.py b/ietf/settings.py index 3aa45a453c0..50e069ff1a5 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -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', ) @@ -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' diff --git a/ietf/stats/views.py b/ietf/stats/views.py index b9cd5c10a27..6bdcceec327 100644 --- a/ietf/stats/views.py +++ b/ietf/stats/views.py @@ -9,6 +9,7 @@ import dateutil.relativedelta from collections import defaultdict +from django.conf import settings from django.contrib.auth.decorators import login_required from django.core.cache import cache from django.http import HttpResponseRedirect @@ -166,7 +167,6 @@ def get_affiliation_data_for_meetings(attendance_type=None): Tuple of (sorted_meetings, datasets) for Chart.js. """ cache_key = f'stats:get_affiliation_data_for_meetings:{attendance_type}' - cache_timeout = 86400 sorted_meetings, datasets = cache.get(cache_key, (None, None)) if (sorted_meetings, datasets) == (None, None): top_n = 20 # could be a parameter, but would need to adjust cache handling @@ -239,7 +239,11 @@ def get_affiliation_data_for_meetings(attendance_type=None): 'pointHoverRadius': 6, 'borderWidth': 2, }) - cache.set(cache_key, (sorted_meetings, datasets), cache_timeout) + cache.set( + cache_key, + (sorted_meetings, datasets), + settings.STATS_TIMELINE_CACHE_TIMEOUT, + ) return sorted_meetings, datasets @@ -253,7 +257,6 @@ def get_country_data_for_meetings(attendance_type=None): Tuple of (sorted_meetings, datasets) for Chart.js. """ cache_key = f'stats:get_country_data_for_meetings:{attendance_type}' - cache_timeout = 86400 sorted_meetings, datasets = cache.get(cache_key, (None, None)) if (sorted_meetings, datasets) == (None, None): top_n = 10 # could be a parameter, but would need to adjust cache handling @@ -335,7 +338,11 @@ def get_country_data_for_meetings(attendance_type=None): 'pointHoverRadius': 6, 'borderWidth': 2, }) - cache.set(cache_key, (sorted_meetings, datasets), cache_timeout) + cache.set( + cache_key, + (sorted_meetings, datasets), + settings.STATS_TIMELINE_CACHE_TIMEOUT, + ) return sorted_meetings, datasets @@ -346,7 +353,6 @@ def get_data_for_meetings(): Tuple of (sorted_meetings, datasets) for Chart.js. """ cache_key = "stats:get_data_for_meetings" - cache_timeout = 86400 sorted_meetings, datasets = cache.get(cache_key, (None, None)) if (sorted_meetings, datasets) == (None, None): # Get registration status counts, aggregated by ticket types @@ -399,7 +405,11 @@ def get_data_for_meetings(): 'pointHoverRadius': 6, 'borderWidth': 2, }) - cache.set(cache_key, (sorted_meetings, datasets), cache_timeout) + cache.set( + cache_key, + (sorted_meetings, datasets), + settings.STATS_TIMELINE_CACHE_TIMEOUT, + ) return sorted_meetings, datasets def meetings_timeline(request, stats_type='country'): From afc9e818e0e46f09bc1714774fa598611d475481 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 27 Apr 2026 11:27:40 -0300 Subject: [PATCH 32/34] refactor: wait for DOMContentLoaded + restyle --- ietf/static/js/meeting_stats.js | 97 ++++++++++---------- ietf/static/js/meeting_timeline.js | 141 +++++++++++++++-------------- 2 files changed, 122 insertions(+), 116 deletions(-) diff --git a/ietf/static/js/meeting_stats.js b/ietf/static/js/meeting_stats.js index 2c26c7e7bfe..70b18a0f03e 100644 --- a/ietf/static/js/meeting_stats.js +++ b/ietf/static/js/meeting_stats.js @@ -1,54 +1,57 @@ -// 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); +// 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, - })); + 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}%)`; + }, + 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) ; + displayChart('totalRegistrationChart', totalChartData) + displayChart('inPersonRegistrationChart', inPersonChartData) +}) diff --git a/ietf/static/js/meeting_timeline.js b/ietf/static/js/meeting_timeline.js index 80d26d2621c..1ab7d95be2d 100644 --- a/ietf/static/js/meeting_timeline.js +++ b/ietf/static/js/meeting_timeline.js @@ -1,81 +1,84 @@ -// ── 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"; +// 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', + 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, }, - }, - }, - plugins: { - legend: { - position: 'bottom', - labels: { - usePointStyle: true, - padding: 15, - font: { size: 12 }, + x: { + title: { + display: true, + text: 'IETF Meeting Number', + }, }, }, - tooltip: { - backgroundColor: 'rgba(0,0,0,0.8)', - titleFont: { size: 14 }, - bodyFont: { size: 13 }, - callbacks: { - title: function(items) { - return `IETF Meeting ${items[0].label}`; + plugins: { + legend: { + position: 'bottom', + labels: { + usePointStyle: true, + padding: 15, + font: { size: 12 }, }, - label: function(context) { - return ` ${context.dataset.label}: ${context.parsed.y} participants`; + }, + 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 + 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(); + 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(); + inPersonChart.resetZoom() } - } -}); -document.getElementById('resetButton').addEventListener('click', () => { - totalChart.resetZoom(); - if (inPersonChart != null) { - inPersonChart.resetZoom(); - } - }); + }) +}) From 0f191954d2510a48da06c50855fa2f4a7bd90e1e Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 27 Apr 2026 11:34:50 -0300 Subject: [PATCH 33/34] fix: fix null checks --- ietf/static/js/meeting_timeline.js | 6 +++--- ietf/stats/views.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ietf/static/js/meeting_timeline.js b/ietf/static/js/meeting_timeline.js index 1ab7d95be2d..161cead0ec5 100644 --- a/ietf/static/js/meeting_timeline.js +++ b/ietf/static/js/meeting_timeline.js @@ -64,20 +64,20 @@ document.addEventListener('DOMContentLoaded', () => { } const totalChart = displayChart('totalRegistrationChart', totalChartData) - if (inPersonChartData != null) { + if (inPersonChartData !== null) { inPersonChart = displayChart('inPersonRegistrationChart', inPersonChartData) } document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { totalChart.resetZoom() - if (inPersonChart != null) { + if (inPersonChart !== null) { inPersonChart.resetZoom() } } }) document.getElementById('resetButton').addEventListener('click', () => { totalChart.resetZoom() - if (inPersonChart != null) { + if (inPersonChart !== null) { inPersonChart.resetZoom() } }) diff --git a/ietf/stats/views.py b/ietf/stats/views.py index 6bdcceec327..d61b6730754 100644 --- a/ietf/stats/views.py +++ b/ietf/stats/views.py @@ -446,7 +446,7 @@ def meetings_timeline(request, stats_type='country'): # On per country/affiliation have a separate graph for inperson if stats_type == 'total': - in_person_chart_data = {} + in_person_chart_data = None else: in_person_chart_data = { 'labels': in_person_labels, From 138e93622c3b36c9cc8e162c316402c05a27b750 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 28 Apr 2026 13:18:35 -0300 Subject: [PATCH 34/34] test: update test_meeting_stats() --- ietf/stats/tests.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ietf/stats/tests.py b/ietf/stats/tests.py index 6dd33d3d3bb..373f06e3431 100644 --- a/ietf/stats/tests.py +++ b/ietf/stats/tests.py @@ -72,7 +72,15 @@ def test_meeting_stats(self): 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]') + # 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)