From d9c240f1c601bd4622aa30be5bc18b85e7ba25f2 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 27 Apr 2026 11:10:24 -0300 Subject: [PATCH 1/6] 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 0000000000..2c26c7e7bf --- /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 0000000000..80d26d2621 --- /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 512764ec0e..f2d689db3e 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 70ce363f7a..b32f5a4046 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 e89eae5196..65fb4e09c0 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 57642d7860..6f61aaba29 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 2/6] 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 f2d689db3e..08643122b3 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 3/6] 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 08643122b3..b9cd5c10a2 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 4/6] 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 3aa45a453c..50e069ff1a 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 b9cd5c10a2..6bdcceec32 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 5/6] 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 2c26c7e7bf..70b18a0f03 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 80d26d2621..1ab7d95be2 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 6/6] 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 1ab7d95be2..161cead0ec 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 6bdcceec32..d61b673075 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,