Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 1 addition & 11 deletions dev/build/datatracker-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,6 @@ cleanup () {
trap 'trap "" TERM; cleanup' TERM

# start gunicorn in the background so we can trap the TERM signal
gunicorn \
-c /workspace/gunicorn.conf.py \
--workers "${DATATRACKER_GUNICORN_WORKERS:-9}" \
--max-requests "${DATATRACKER_GUNICORN_MAX_REQUESTS:-32768}" \
--timeout "${DATATRACKER_GUNICORN_TIMEOUT:-180}" \
--bind :8000 \
--log-level "${DATATRACKER_GUNICORN_LOG_LEVEL:-info}" \
--capture-output \
--access-logfile -\
${DATATRACKER_GUNICORN_EXTRA_ARGS} \
ietf.wsgi:application &
gunicorn -c /workspace/gunicorn.conf.py ${DATATRACKER_GUNICORN_EXTRA_ARGS} ietf.wsgi:application &
gunicorn_pid=$!
wait "${gunicorn_pid}"
18 changes: 17 additions & 1 deletion dev/build/gunicorn.conf.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright The IETF Trust 2024-2025, All Rights Reserved
# Copyright The IETF Trust 2024-2026, All Rights Reserved

import os
import ietf
Expand All @@ -12,6 +12,22 @@
from opentelemetry.instrumentation.pymemcache import PymemcacheInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor

# Bind interfaces for both ipv4 and ipv6
bind = ["127.0.0.1:8000", "[::1]:8000"]

# Disable control socket
control_socket_disable = True

# Settings configurable via environment
workers = int(os.environ.get("DATATRACKER_GUNICORN_WORKERS", "9"))
max_requests = int(os.environ.get("DATATRACKER_GUNICORN_MAX_REQUESTS", "32768"))
timeout = int(os.environ.get("DATATRACKER_GUNICORN_TIMEOUT", "180"))
loglevel = os.environ.get("DATATRACKER_GUNICORN_LOG_LEVEL", "info")

# Logging / stdout capture
capture_output = True
accesslog = "-"

# Configure security scheme headers for forwarded requests. Cloudflare sets X-Forwarded-Proto
# for us. Don't trust any of the other similar headers. Only trust the header if it's coming
# from localhost, as all legitimate traffic will reach gunicorn via co-located nginx.
Expand Down
3 changes: 2 additions & 1 deletion ietf/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
AGENDA_CACHE_TIMEOUT_DEFAULT = 8 * 24 * 60 * 60 # 8 days
AGENDA_CACHE_TIMEOUT_CURRENT_MEETING = 6 * 60 # 6 minutes


WSGI_APPLICATION = "ietf.wsgi.application"

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

STATS_NAMES_LIMIT = 25
STATS_TIMELINE_CACHE_TIMEOUT = 86400

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

function displayChart (id, data) {
const ctx = document.getElementById(id).getContext('2d')
new Chart(ctx, {
type: 'pie', // Change to 'doughnut' for a donut chart
data: data,
options: {
responsive: true,
plugins: {
autocolors: {
mode: 'data' // Required for Pie charts to color individual slices
},
legend: {
position: 'bottom',
labels: {
padding: 20,
font: { size: 13 },
color: '#475569',
generateLabels: function (chart) {
const dataset = chart.data.datasets[0]
return chart.data.labels.map((label, i) => ({
text: `${label}: ${dataset.data[i]}`,
fillStyle: dataset.backgroundColor[i],
hidden: false,
index: i,
}))
}
}
},
tooltip: {
callbacks: {
label: function (context) {
const label = context.label || ''
const value = context.raw
const total = context.dataset.data.reduce((a, b) => a + b, 0)
const percentage = ((value / total) * 100).toFixed(1)

return `${label}: ${value} (${percentage}%)`
}
}
}
}
}
})
}

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

function displayChart (id, data) {
const ctx = document.getElementById(id).getContext('2d')
return new Chart(ctx, {
type: 'line', // Change to 'doughnut' for a donut chart
data: data,
options: {
responsive: true,
scales: {
y: {
stacked: stackedLines,
},
x: {
title: {
display: true,
text: 'IETF Meeting Number',
},
},
},
plugins: {
legend: {
position: 'bottom',
labels: {
usePointStyle: true,
padding: 15,
font: { size: 12 },
},
},
tooltip: {
backgroundColor: 'rgba(0,0,0,0.8)',
titleFont: { size: 14 },
bodyFont: { size: 13 },
callbacks: {
title: function (items) {
return `IETF Meeting ${items[0].label}`
},
label: function (context) {
return ` ${context.dataset.label}: ${context.parsed.y} participants`
}
}
},
zoom: {
zoom: {
wheel: { enabled: true }, // scroll to zoom
pinch: { enabled: true }, // pinch on mobile
drag: { enabled: true }, // drag to select range
mode: 'xy', // zoom X-axis and Y-axis
},
pan: {
enabled: true,
mode: 'xy', // pan X-axis and Y-axis
},
},
}
}
})
}

const totalChart = displayChart('totalRegistrationChart', totalChartData)
if (inPersonChartData !== null) {
inPersonChart = displayChart('inPersonRegistrationChart', inPersonChartData)
}
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
totalChart.resetZoom()
if (inPersonChart !== null) {
inPersonChart.resetZoom()
}
}
})
document.getElementById('resetButton').addEventListener('click', () => {
totalChart.resetZoom()
if (inPersonChart !== null) {
inPersonChart.resetZoom()
}
})
})
63 changes: 57 additions & 6 deletions ietf/stats/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

import calendar
import json
import datetime

from pyquery import PyQuery

import debug # pyflakes:ignore

from django.urls import reverse as urlreverse
from django.utils import timezone

from ietf.utils.test_utils import login_testing_unauthorized, TestCase
import ietf.stats.views
Expand All @@ -18,24 +20,73 @@
from ietf.group.factories import RoleFactory
from ietf.person.factories import PersonFactory
from ietf.review.factories import ReviewRequestFactory, ReviewerSettingsFactory, ReviewAssignmentFactory
from ietf.meeting.tests_models import MeetingFactory, RegistrationFactory
from ietf.utils.timezone import date_today


class StatisticsTests(TestCase):
def test_stats_index(self):
# Create a meeting as the index page needs to know the current meeting
MeetingFactory(type_id='ietf', number='124', date=timezone.now())
url = urlreverse(ietf.stats.views.stats_index)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)

def test_document_stats(self):
r = self.client.get(urlreverse("ietf.stats.views.document_stats"))
self.assertRedirects(r, urlreverse("ietf.stats.views.stats_index"))

# Create a meeting as the index page needs to know the current meeting
MeetingFactory(type_id='ietf', number='124', date=timezone.now())
r = self.client.get(urlreverse(ietf.stats.views.document_stats))
self.assertRedirects(r, urlreverse(ietf.stats.views.stats_index))

def test_meeting_stats(self):
r = self.client.get(urlreverse("ietf.stats.views.meeting_stats"))
self.assertRedirects(r, urlreverse("ietf.stats.views.stats_index"))

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

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