This page provides the top-{{ top_n }} {{ stats_type }} for IETF {{ doc_type|upper }} {{ objects}}.
Only the top-{{ top_n }} categories are listed, the remaining ones are aggregated into 'Other'.
{% if objects == 'authors' %}
- Note: authors are counted for as many documents they have authored.
+ Note: authors are counted only once no matter how many documents they have authored.
{% endif %}
From dc192af805f1717e1c461ab7d83b56aa33b15038 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Fri, 3 Jul 2026 08:31:02 +0000
Subject: [PATCH 122/181] Use RfcAuthor for RFC rather than DocumentAuthor
---
ietf/stats/views_authors.py | 122 +++++++++++++++++++++++++-----------
1 file changed, 86 insertions(+), 36 deletions(-)
diff --git a/ietf/stats/views_authors.py b/ietf/stats/views_authors.py
index 7e80e0ab580..9adbb510e70 100644
--- a/ietf/stats/views_authors.py
+++ b/ietf/stats/views_authors.py
@@ -11,41 +11,60 @@
import debug # pyflakes:ignore
-from ietf.doc.models import DocumentAuthor
+from ietf.doc.models import DocumentAuthor, RfcAuthor
from ietf.stats.utils import color_from_hash, get_aliased_affiliations, get_aliased_countries, check_top_n_choice, get_top_n_choices
def get_authors_total_data_for_documents(doc_type: str = 'all', group_by: str = 'country', top_n: int = 20) -> dict[str, object]:
"""Build chart data for author totals.
Args:
- doc_type: Document category to filter on.
- group_by: Field used to group authors.
+ doc_type: Document category to filter on: all, draft, wg-draft, rfc,
+ group_by: Field used to group authors, e.g., 'country' or 'affiliation'.
top_n: Maximum number of top groups to include before aggregating into Other.
Returns:
A Chart.js-compatible data dictionary.
"""
- # Build a dynamic query set filter
- # RfcAuthor to get country/affiliation for RFC
+ # Build a dynamic query set filter to get country/affiliation using the appropriate model based on doc_type.
+ # RfcAuthor for RFC
# DocumentAuthor for other documents (i.e., drafts)
- filters = Q()
- if doc_type != 'all' and doc_type != 'wg-draft':
- filters &= Q(document__type_id=doc_type)
- elif doc_type == 'wg-draft':
- filters &= Q(document__type_id= 'draft')
- filters &= Q(document__group__type_id="wg")
- queryset = (
- DocumentAuthor.objects
- .filter(filters)
- .values(group_by)
- .annotate(author_count=Count('person', distinct=True)) # Count as many document authored by this author
- )
-
- group_count_set = {
- (group, count)
- for group, count in queryset.values_list(group_by, 'author_count')
- }
+
+ # Using distinct=True in Count to avoid double counting authors who may have multiple entries in the database
+ if doc_type in ('draft', 'wg-draft'):
+ filters = Q(document__type_id='draft')
+ if doc_type == 'wg-draft':
+ filters &= Q(document__group__type_id='wg')
+ queryset = (
+ DocumentAuthor.objects
+ .filter(filters)
+ .values(group_by)
+ .annotate(author_count=Count('person', distinct=True))
+ )
+ elif doc_type == 'rfc':
+ queryset = (
+ RfcAuthor.objects
+ .values(group_by)
+ .annotate(author_count=Count('person', distinct=True))
+ )
+ else:
+ draft_queryset = (
+ DocumentAuthor.objects
+ .filter(document__type_id='draft')
+ .values(group_by)
+ .annotate(author_count=Count('person', distinct=True))
+ )
+ rfc_queryset = (
+ RfcAuthor.objects
+ .values(group_by)
+ .annotate(author_count=Count('person', distinct=True))
+ )
+ queryset = draft_queryset.union(rfc_queryset, all=True)
+
+ group_count_set = [
+ (row.get(group_by), row.get('author_count', 0))
+ for row in queryset
+ ]
if group_by == 'affiliation':
alias_map = get_aliased_affiliations(group for group, _ in group_count_set)
@@ -142,8 +161,8 @@ def get_authors_timeline_data_for_documents(doc_type: str = 'all', group_by: str
"""Build timeline datasets for author statistics.
Args:
- doc_type: Document category to filter on.
- group_by: Field used to group authors.
+ doc_type: Document category to filter on: all, draft, wg-draft, rfc,
+ group_by: Field used to group authors, e.g., 'country' or 'affiliation'.
top_n: Maximum number of top groups to include before aggregating into Other.
Returns:
@@ -155,18 +174,49 @@ def get_authors_timeline_data_for_documents(doc_type: str = 'all', group_by: str
if result is not None:
years_list, documents_totals, data_map = result
else:
- # Build a dynamic query set filter
- filters = Q()
- if doc_type != 'all' and doc_type != 'wg-draft':
- filters &= Q(document__type_id=doc_type)
- if doc_type == 'wg-draft':
- filters &= Q(document__type_id= 'draft')
- filters &= Q(document__group__type_id="wg")
- queryset = (
- DocumentAuthor.objects
- .select_related('document')
- .filter(filters)
- )
+ # # Build a dynamic query set filter
+ # filters = Q()
+ # if doc_type != 'all' and doc_type != 'wg-draft':
+ # filters &= Q(document__type_id=doc_type)
+ # if doc_type == 'wg-draft':
+ # filters &= Q(document__type_id= 'draft')
+ # filters &= Q(document__group__type_id="wg")
+ # queryset = (
+ # DocumentAuthor.objects
+ # .select_related('document')
+ # .filter(filters)
+ # )
+ # Build a dynamic query set filter to get country/affiliation using the appropriate model based on doc_type.
+ # RfcAuthor for RFC
+ # DocumentAuthor for other documents (i.e., drafts)
+
+ # Using distinct=True in Count to avoid double counting authors who may have multiple entries in the database
+ if doc_type in ('draft', 'wg-draft'):
+ filters = Q(document__type_id='draft')
+ if doc_type == 'wg-draft':
+ filters &= Q(document__group__type_id='wg')
+ queryset = (
+ DocumentAuthor.objects
+ .filter(filters)
+ .select_related('document')
+ )
+ elif doc_type == 'rfc':
+ queryset = (
+ RfcAuthor.objects
+ .select_related('document')
+ )
+ else:
+ draft_queryset = (
+ DocumentAuthor.objects
+ .filter(document__type_id='draft')
+ .select_related('document')
+ )
+ rfc_queryset = (
+ RfcAuthor.objects
+ .select_related('document')
+ )
+ queryset = draft_queryset.union(rfc_queryset, all=True)
+
# ── Step 1: Collect all meetings and tickets totals ──
years_set = set()
From 68e20a6835fa7802a00a7e4f64ea569e5df81489 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Fri, 3 Jul 2026 09:43:41 +0000
Subject: [PATCH 123/181] Make ruff happy
---
ietf/stats/views_reviews.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ietf/stats/views_reviews.py b/ietf/stats/views_reviews.py
index a77532567ec..d9bc06c0edc 100644
--- a/ietf/stats/views_reviews.py
+++ b/ietf/stats/views_reviews.py
@@ -192,7 +192,7 @@ def parse_date(s):
secr_access.add(r.group_id)
reviewer_only_access.discard(r.group_id)
elif r.name_id == "reviewer":
- if not r.group_id in secr_access:
+ if r.group_id not in secr_access:
reviewer_only_access.add(r.group_id)
if not secr_access and not reviewer_only_access:
From c98f3399458230605a57e14822d62c760e8e3f13 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Fri, 3 Jul 2026 09:44:02 +0000
Subject: [PATCH 124/181] More changes in order to use RfcAuthor
---
ietf/stats/tests.py | 14 +++++++-------
ietf/stats/views_authors.py | 35 ++++++++++++++++-------------------
2 files changed, 23 insertions(+), 26 deletions(-)
diff --git a/ietf/stats/tests.py b/ietf/stats/tests.py
index 9b070cf4bed..cd65fe53545 100644
--- a/ietf/stats/tests.py
+++ b/ietf/stats/tests.py
@@ -20,7 +20,7 @@
from ietf.utils.test_utils import login_testing_unauthorized, TestCase
import ietf.stats.views
-from ietf.doc.factories import WgDraftFactory, WgRfcFactory, DocumentAuthorFactory, DocumentFactory, DocEventFactory, NewRevisionDocEventFactory
+from ietf.doc.factories import WgDraftFactory, WgRfcFactory, DocumentAuthorFactory, RfcAuthorFactory, DocumentFactory, DocEventFactory, NewRevisionDocEventFactory
from ietf.review.factories import ReviewRequestFactory, ReviewerSettingsFactory, ReviewAssignmentFactory
from ietf.stats.factories import AffiliationIgnoredEndingFactory, AffiliationMainNameFactory
from ietf.group.factories import GroupFactory, RoleFactory
@@ -103,15 +103,15 @@ def test_document_stats(self):
AffiliationIgnoredEndingFactory(ending='corp\\.?')
AffiliationMainNameFactory(main_name='Cisco')
- DocumentAuthorFactory(document=rfcPsGroup1, affiliation=affiliation, country=country)
- DocumentAuthorFactory(document=rfcExpGroup1, affiliation=affiliation + ', LLC', country=country)
- DocumentAuthorFactory(document=rfcExpGroup1, affiliation=factory.Faker('company'), country=factory.Faker('country'))
+ RfcAuthorFactory(document=rfcPsGroup1, affiliation=affiliation, country=country)
+ RfcAuthorFactory(document=rfcExpGroup1, affiliation=affiliation + ', LLC', country=country)
+ RfcAuthorFactory(document=rfcExpGroup1, affiliation=factory.Faker('company'), country=factory.Faker('country'))
DocumentAuthorFactory(document=wgDraftPsGroup1, affiliation=affiliation + ' AG', country=country)
- DocumentAuthorFactory(document=rfcInfGroup2, affiliation='CiScO InC.', country=country)
+ RfcAuthorFactory(document=rfcInfGroup2, affiliation='CiScO InC.', country=country)
DocumentAuthorFactory(document=wgDraftPsGroup2, affiliation='CISCO corp.', country='belgique')
DocumentAuthorFactory(document=wgDraftPsGroup2, affiliation=affiliation, country=country)
- DocumentAuthorFactory(document=rfcBcpIAB1, affiliation='CiScO PTY LTD', country='UnItEd StAtEs')
- DocumentAuthorFactory(document=rfcBcpIAB2, affiliation=affiliation, country='usa')
+ RfcAuthorFactory(document=rfcBcpIAB1, affiliation='CiScO PTY LTD', country='UnItEd StAtEs')
+ RfcAuthorFactory(document=rfcBcpIAB2, affiliation=affiliation, country='usa')
DocumentAuthorFactory(document=draftExp, affiliation=affiliation + ',inc', country='U.S.A.')
# Test#1 the documents specific statistics: for RFC about the level
diff --git a/ietf/stats/views_authors.py b/ietf/stats/views_authors.py
index 9adbb510e70..9e3d2c7fa4a 100644
--- a/ietf/stats/views_authors.py
+++ b/ietf/stats/views_authors.py
@@ -174,34 +174,24 @@ def get_authors_timeline_data_for_documents(doc_type: str = 'all', group_by: str
if result is not None:
years_list, documents_totals, data_map = result
else:
- # # Build a dynamic query set filter
- # filters = Q()
- # if doc_type != 'all' and doc_type != 'wg-draft':
- # filters &= Q(document__type_id=doc_type)
- # if doc_type == 'wg-draft':
- # filters &= Q(document__type_id= 'draft')
- # filters &= Q(document__group__type_id="wg")
- # queryset = (
- # DocumentAuthor.objects
- # .select_related('document')
- # .filter(filters)
- # )
# Build a dynamic query set filter to get country/affiliation using the appropriate model based on doc_type.
# RfcAuthor for RFC
# DocumentAuthor for other documents (i.e., drafts)
# Using distinct=True in Count to avoid double counting authors who may have multiple entries in the database
+ draft_queryset = None
+ rfc_queryset = None
if doc_type in ('draft', 'wg-draft'):
filters = Q(document__type_id='draft')
if doc_type == 'wg-draft':
filters &= Q(document__group__type_id='wg')
- queryset = (
+ draft_queryset = (
DocumentAuthor.objects
.filter(filters)
.select_related('document')
)
elif doc_type == 'rfc':
- queryset = (
+ rfc_queryset = (
RfcAuthor.objects
.select_related('document')
)
@@ -215,18 +205,25 @@ def get_authors_timeline_data_for_documents(doc_type: str = 'all', group_by: str
RfcAuthor.objects
.select_related('document')
)
- queryset = draft_queryset.union(rfc_queryset, all=True)
-
- # ── Step 1: Collect all meetings and tickets totals ──
+ # ── Step 1: Collect all authors publication dates ──
years_set = set()
documents_totals = defaultdict(int)
data_map = defaultdict(dict)
- year_group_list = [
+ year_group_list = []
+ if draft_queryset is not None:
+ year_group_list += [
+ (row.document.pub_date().year, getattr(row, group_by))
+ for row in draft_queryset
+ if row.document.pub_date() is not None
+ ]
+ if rfc_queryset is not None:
+ year_group_list += [
(row.document.pub_date().year, getattr(row, group_by))
- for row in queryset
+ for row in rfc_queryset
if row.document.pub_date() is not None
]
+
if group_by == 'affiliation':
alias_map = get_aliased_affiliations(group for _, group in year_group_list)
year_group_list = [(year, alias_map.get(group, group)) for year, group in year_group_list]
From 5944234d00c1ccad25ea8fb37cc6c5360eea3e6b Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Fri, 3 Jul 2026 10:03:34 +0000
Subject: [PATCH 125/181] Make ruff happier
---
ietf/stats/views_authors.py | 235 +++++++++++++++++++-----------------
1 file changed, 123 insertions(+), 112 deletions(-)
diff --git a/ietf/stats/views_authors.py b/ietf/stats/views_authors.py
index 9e3d2c7fa4a..582212e0e01 100644
--- a/ietf/stats/views_authors.py
+++ b/ietf/stats/views_authors.py
@@ -1,5 +1,4 @@
# Copyright The IETF Trust 2016-2026, All Rights Reserved
-# -*- coding: utf-8 -*-
from django.conf import settings
from django.db.models import Count, Q
@@ -12,98 +11,109 @@
import debug # pyflakes:ignore
from ietf.doc.models import DocumentAuthor, RfcAuthor
-from ietf.stats.utils import color_from_hash, get_aliased_affiliations, get_aliased_countries, check_top_n_choice, get_top_n_choices
-
-def get_authors_total_data_for_documents(doc_type: str = 'all', group_by: str = 'country', top_n: int = 20) -> dict[str, object]:
+from ietf.stats.utils import (
+ check_top_n_choice,
+ color_from_hash,
+ get_aliased_affiliations,
+ get_aliased_countries,
+ get_top_n_choices,
+)
+
+def get_authors_total_data_for_documents(doc_type: str = "all",
+ group_by: str = "country",
+ top_n: int = 20) -> dict[str, object]:
"""Build chart data for author totals.
Args:
doc_type: Document category to filter on: all, draft, wg-draft, rfc,
- group_by: Field used to group authors, e.g., 'country' or 'affiliation'.
+ group_by: Field used to group authors, e.g., "country" or "affiliation".
top_n: Maximum number of top groups to include before aggregating into Other.
Returns:
A Chart.js-compatible data dictionary.
- """
- # Build a dynamic query set filter to get country/affiliation using the appropriate model based on doc_type.
+ """
+ # Build a dynamic query set filter to get country/affiliation
+ # using the appropriate model based on doc_type.
# RfcAuthor for RFC
# DocumentAuthor for other documents (i.e., drafts)
- # Using distinct=True in Count to avoid double counting authors who may have multiple entries in the database
- if doc_type in ('draft', 'wg-draft'):
- filters = Q(document__type_id='draft')
- if doc_type == 'wg-draft':
- filters &= Q(document__group__type_id='wg')
+ # Using distinct=True in Count to avoid double counting authors
+ # who may have multiple entries in the database
+ if doc_type in ("draft", "wg-draft"):
+ filters = Q(document__type_id="draft")
+ if doc_type == "wg-draft":
+ filters &= Q(document__group__type_id="wg")
queryset = (
DocumentAuthor.objects
.filter(filters)
.values(group_by)
- .annotate(author_count=Count('person', distinct=True))
+ .annotate(author_count=Count("person", distinct=True))
)
- elif doc_type == 'rfc':
+ elif doc_type == "rfc":
queryset = (
RfcAuthor.objects
.values(group_by)
- .annotate(author_count=Count('person', distinct=True))
+ .annotate(author_count=Count("person", distinct=True))
)
else:
draft_queryset = (
DocumentAuthor.objects
- .filter(document__type_id='draft')
+ .filter(document__type_id="draft")
.values(group_by)
- .annotate(author_count=Count('person', distinct=True))
+ .annotate(author_count=Count("person", distinct=True))
)
rfc_queryset = (
RfcAuthor.objects
.values(group_by)
- .annotate(author_count=Count('person', distinct=True))
+ .annotate(author_count=Count("person", distinct=True))
)
queryset = draft_queryset.union(rfc_queryset, all=True)
group_count_set = [
- (row.get(group_by), row.get('author_count', 0))
+ (row.get(group_by), row.get("author_count", 0))
for row in queryset
]
- if group_by == 'affiliation':
+ if group_by == "affiliation":
alias_map = get_aliased_affiliations(group for group, _ in group_count_set)
- elif group_by == 'country':
+ elif group_by == "country":
alias_map = get_aliased_countries(group for group, _ in group_count_set)
else:
alias_map = {}
group_count_dict: dict[str, int] = {}
for group, count in group_count_set:
- group = alias_map.get(group, group)
- if not group:
- group = 'Unspecified'
- else:
- group = str(group)
- group_count_dict[group] = group_count_dict.get(group, 0) + count
+ aliased_group = alias_map.get(group, group)
+ aliased_group = "Unspecified" if not aliased_group else str(aliased_group)
+ group_count_dict[aliased_group] = group_count_dict.get(aliased_group, 0) + count
- group_count_sorted = sorted(group_count_dict.items(), key=lambda x: x[1], reverse=True)
+ group_count_sorted = sorted(group_count_dict.items(),
+ key=lambda x: x[1], reverse=True)
top_groups = group_count_sorted[:top_n]
other_count = sum(count for _, count in group_count_sorted[top_n:])
if other_count > 0:
- top_groups.append(('Other', other_count))
+ top_groups.append(("Other", other_count))
labels, data = zip(*top_groups) if top_groups else ((), ())
labels_list = list(labels)
data_list = list(data)
chart_data: dict[str, object] = {
- 'labels': labels_list,
- 'datasets': [{
- 'data': data_list,
- 'backgroundColor': [color_from_hash(label) if label else '#202020' for label in labels_list],
- 'borderColor': 'black',
- 'borderWidth': 1,
+ "labels": labels_list,
+ "datasets": [{
+ "data": data_list,
+ "backgroundColor": [color_from_hash(label)
+ if label else "#202020"
+ for label in labels_list],
+ "borderColor": "black",
+ "borderWidth": 1,
}],
}
return chart_data
-def authors_total(request: HttpRequest, doc_type: str = 'all', stats_type: str = 'affiliation') -> HttpResponse:
+def authors_total(request: HttpRequest, doc_type: str = "all",
+ stats_type: str = "affiliation") -> HttpResponse:
"""Render total author statistics.
Args:
@@ -113,34 +123,35 @@ def authors_total(request: HttpRequest, doc_type: str = 'all', stats_type: str =
Returns:
Rendered response for the total statistics page.
- """
+ """
# Query parameters (from ?key=value)
try:
- top_n = max(1, min(int(request.GET.get('top', '10')), 100))
+ top_n = max(1, min(int(request.GET.get("top", "10")), 100))
except ValueError:
top_n = 10
# Check the top-n value against the allowed choices
if not check_top_n_choice(top_n):
- return render(request, "stats/error.html", {"message": f"Invalid top_n choice: {top_n}. Valid choices are: {get_top_n_choices()}"})
+ return render(request, "stats/error.html",
+ {"message": f"Invalid top_n choice: {top_n}. Valid choices are: {get_top_n_choices()}"})
- if stats_type == 'affiliation':
- chart_data = get_authors_total_data_for_documents(doc_type, 'affiliation', top_n)
- elif stats_type == 'country':
- chart_data = get_authors_total_data_for_documents(doc_type, 'country', top_n)
+ if stats_type == "affiliation":
+ chart_data = get_authors_total_data_for_documents(doc_type, "affiliation", top_n)
+ elif stats_type == "country":
+ chart_data = get_authors_total_data_for_documents(doc_type, "country", top_n)
else:
return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
# Prepare the list of choice buttons for the template
possible_docs_types = [
- ("all", "All documents", urlreverse(authors_total, kwargs={'doc_type': 'all', 'stats_type': stats_type})),
- ("draft", "Drafts", urlreverse(authors_total, kwargs={'doc_type': 'draft', 'stats_type': stats_type})),
- ("wg-draft", "WG Drafts", urlreverse(authors_total, kwargs={'doc_type': 'wg-draft', 'stats_type': stats_type})),
- ("rfc", "RFCs", urlreverse(authors_total, kwargs={'doc_type': 'rfc', 'stats_type': stats_type})),
+ ("all", "All documents", urlreverse(authors_total, kwargs={"doc_type": "all", "stats_type": stats_type})),
+ ("draft", "Drafts", urlreverse(authors_total, kwargs={"doc_type": "draft", "stats_type": stats_type})),
+ ("wg-draft", "WG Drafts", urlreverse(authors_total, kwargs={"doc_type": "wg-draft", "stats_type": stats_type})),
+ ("rfc", "RFCs", urlreverse(authors_total, kwargs={"doc_type": "rfc", "stats_type": stats_type})),
]
possible_stats_types = [
- ("affiliation", "Affiliation", urlreverse(authors_total, kwargs={'doc_type': doc_type, 'stats_type': 'affiliation'})),
- ("country", "Country", urlreverse(authors_total, kwargs={'doc_type': doc_type, 'stats_type': 'country'})),
+ ("affiliation", "Affiliation", urlreverse(authors_total, kwargs={"doc_type": doc_type, "stats_type": "affiliation"})),
+ ("country", "Country", urlreverse(authors_total, kwargs={"doc_type": doc_type, "stats_type": "country"})),
]
return render(request, "stats/documents_total.html", {
@@ -149,27 +160,27 @@ def authors_total(request: HttpRequest, doc_type: str = 'all', stats_type: str =
"objects": "authors",
"possible_docs_types": possible_docs_types,
"possible_stats_types": possible_stats_types,
- "timeline_url": urlreverse(authors_timeline, kwargs={'doc_type': doc_type, 'stats_type': stats_type}),
- "total_url": urlreverse(authors_total, kwargs={'doc_type': doc_type, 'stats_type': stats_type}),
+ "timeline_url": urlreverse(authors_timeline, kwargs={"doc_type": doc_type, "stats_type": stats_type}),
+ "total_url": urlreverse(authors_total, kwargs={"doc_type": doc_type, "stats_type": stats_type}),
"doc_type": doc_type,
"stats_type": stats_type,
"chart_data": chart_data,
})
-def get_authors_timeline_data_for_documents(doc_type: str = 'all', group_by: str = 'country', top_n: int = 10) -> tuple[list[int], list[dict[str, object]]]:
+def get_authors_timeline_data_for_documents(doc_type: str = "all", group_by: str = "country", top_n: int = 10) -> tuple[list[int], list[dict[str, object]]]:
"""Build timeline datasets for author statistics.
Args:
doc_type: Document category to filter on: all, draft, wg-draft, rfc,
- group_by: Field used to group authors, e.g., 'country' or 'affiliation'.
+ group_by: Field used to group authors, e.g., "country" or "affiliation".
top_n: Maximum number of top groups to include before aggregating into Other.
Returns:
A tuple containing the ordered years list and Chart.js datasets.
- """
- cache_key = f'stats:get_authors_timeline_data_for_documents:{doc_type}-{group_by}'
+ """
+ cache_key = f"stats:get_authors_timeline_data_for_documents:{doc_type}-{group_by}"
result = cache.get(cache_key, None)
if result is not None:
years_list, documents_totals, data_map = result
@@ -181,29 +192,29 @@ def get_authors_timeline_data_for_documents(doc_type: str = 'all', group_by: str
# Using distinct=True in Count to avoid double counting authors who may have multiple entries in the database
draft_queryset = None
rfc_queryset = None
- if doc_type in ('draft', 'wg-draft'):
- filters = Q(document__type_id='draft')
- if doc_type == 'wg-draft':
- filters &= Q(document__group__type_id='wg')
+ if doc_type in ("draft", "wg-draft"):
+ filters = Q(document__type_id="draft")
+ if doc_type == "wg-draft":
+ filters &= Q(document__group__type_id="wg")
draft_queryset = (
DocumentAuthor.objects
.filter(filters)
- .select_related('document')
+ .select_related("document")
)
- elif doc_type == 'rfc':
+ elif doc_type == "rfc":
rfc_queryset = (
RfcAuthor.objects
- .select_related('document')
+ .select_related("document")
)
else:
draft_queryset = (
DocumentAuthor.objects
- .filter(document__type_id='draft')
- .select_related('document')
+ .filter(document__type_id="draft")
+ .select_related("document")
)
rfc_queryset = (
RfcAuthor.objects
- .select_related('document')
+ .select_related("document")
)
# ── Step 1: Collect all authors publication dates ──
@@ -223,24 +234,24 @@ def get_authors_timeline_data_for_documents(doc_type: str = 'all', group_by: str
for row in rfc_queryset
if row.document.pub_date() is not None
]
-
- if group_by == 'affiliation':
+
+ if group_by == "affiliation":
alias_map = get_aliased_affiliations(group for _, group in year_group_list)
year_group_list = [(year, alias_map.get(group, group)) for year, group in year_group_list]
- elif group_by == 'country':
+ elif group_by == "country":
alias_map = get_aliased_countries(group for _, group in year_group_list)
year_group_list = [(year, alias_map.get(group, group)) for year, group in year_group_list]
else:
alias_map = {}
- # Let's define a value when there is none...
- alias_map[''] = 'Unspecified'
+ # Let us define a value when there is none...
+ alias_map[""] = "Unspecified"
years_set = {year for year, _ in year_group_list}
for year, group in year_group_list:
- group = alias_map.get(group, group)
- data_map[year][group] = data_map[year].get(group, 0) + 1
- documents_totals[group] += 1
+ aliased_group = alias_map.get(group, group)
+ data_map[year][aliased_group] = data_map[year].get(aliased_group, 0) + 1
+ documents_totals[aliased_group] += 1
# ── Step 2: Sort years numerically rather than alphabetically ──
years_list = sorted(years_set)
@@ -254,7 +265,7 @@ def get_authors_timeline_data_for_documents(doc_type: str = 'all', group_by: str
top_groups = sorted(
documents_totals.keys(),
key=lambda c: documents_totals[c],
- reverse=True
+ reverse=True,
)[:top_n]
non_top_groups = documents_totals.keys() - set(top_groups)
other_totals: dict[int, int] = defaultdict(int)
@@ -268,37 +279,37 @@ def get_authors_timeline_data_for_documents(doc_type: str = 'all', group_by: str
for group in top_groups:
color = color_from_hash(group)
datasets.append({
- 'label': group,
- 'data': [data_map[year].get(group, 0) for year in years_list],
- 'borderColor': color,
- 'backgroundColor': color + '99', # 60% opacity fill
- 'fill': False,
- 'tension': 0.0,
- 'pointColor': color,
- 'pointBackgroundColor': color,
- 'pointRadius': 4,
- 'pointHoverRadius': 6,
- 'borderWidth': 2,
+ "label": group,
+ "data": [data_map[year].get(group, 0) for year in years_list],
+ "borderColor": color,
+ "backgroundColor": color + "99", # 60% opacity fill
+ "fill": False,
+ "tension": 0.0,
+ "pointColor": color,
+ "pointBackgroundColor": color,
+ "pointRadius": 4,
+ "pointHoverRadius": 6,
+ "borderWidth": 2,
})
# -- Step 4.bis handle the other --
datasets.append({
- 'label': 'Other',
- 'data': [other_totals.get(year, 0) for year in years_list],
- 'borderColor': 'black',
- 'fill': False,
- 'tension': 0.0,
- 'pointColor': 'black',
- 'pointBackgroundColor': 'black',
- 'pointRadius': 4,
- 'pointHoverRadius': 6,
- 'borderWidth': 2,
+ "label": "Other",
+ "data": [other_totals.get(year, 0) for year in years_list],
+ "borderColor": "black",
+ "fill": False,
+ "tension": 0.0,
+ "pointColor": "black",
+ "pointBackgroundColor": "black",
+ "pointRadius": 4,
+ "pointHoverRadius": 6,
+ "borderWidth": 2,
})
return years_list, datasets
-def authors_timeline(request: HttpRequest, doc_type: str = 'all', stats_type: str = 'affiliation') -> HttpResponse:
+def authors_timeline(request: HttpRequest, doc_type: str = "all", stats_type: str = "affiliation") -> HttpResponse:
"""Render author timeline statistics.
Args:
@@ -308,39 +319,39 @@ def authors_timeline(request: HttpRequest, doc_type: str = 'all', stats_type: st
Returns:
Rendered response for the timeline statistics page.
- """
+ """
# Query parameters (from ?key=value)
try:
- top_n = max(1, min(int(request.GET.get('top', '20')), 100))
+ top_n = max(1, min(int(request.GET.get("top", "20")), 100))
except ValueError:
top_n = 20
# Check the top-n value against the allowed choices
if not check_top_n_choice(top_n):
return render(request, "stats/error.html", {"message": f"Invalid top_n choice: {top_n}. Valid choices are: {get_top_n_choices()}"})
- if stats_type == 'affiliation':
- total_labels, total_data_sets = get_authors_timeline_data_for_documents(doc_type, 'affiliation', top_n)
- elif stats_type == 'country':
- total_labels, total_data_sets = get_authors_timeline_data_for_documents(doc_type, 'country', top_n)
+ if stats_type == "affiliation":
+ total_labels, total_data_sets = get_authors_timeline_data_for_documents(doc_type, "affiliation", top_n)
+ elif stats_type == "country":
+ total_labels, total_data_sets = get_authors_timeline_data_for_documents(doc_type, "country", top_n)
else:
return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
chart_data = {
- 'labels': total_labels,
- 'datasets': total_data_sets,
+ "labels": total_labels,
+ "datasets": total_data_sets,
}
# Prepare the list of choice buttons for the template
possible_docs_types = [
- ("all", "All documents", urlreverse(authors_timeline, kwargs={'doc_type': 'all', 'stats_type': stats_type})),
- ("draft", "Drafts", urlreverse(authors_timeline, kwargs={'doc_type': 'draft', 'stats_type': stats_type})),
- ("wg-draft", "WG Drafts", urlreverse(authors_timeline, kwargs={'doc_type': 'wg-draft', 'stats_type': stats_type})),
- ("rfc", "RFCs", urlreverse(authors_timeline, kwargs={'doc_type': 'rfc', 'stats_type': stats_type})),
+ ("all", "All documents", urlreverse(authors_timeline, kwargs={"doc_type": "all", "stats_type": stats_type})),
+ ("draft", "Drafts", urlreverse(authors_timeline, kwargs={"doc_type": "draft", "stats_type": stats_type})),
+ ("wg-draft", "WG Drafts", urlreverse(authors_timeline, kwargs={"doc_type": "wg-draft", "stats_type": stats_type})),
+ ("rfc", "RFCs", urlreverse(authors_timeline, kwargs={"doc_type": "rfc", "stats_type": stats_type})),
]
possible_stats_types = [
- ("affiliation", "Affiliation", urlreverse(authors_timeline, kwargs={'doc_type': doc_type, 'stats_type': 'affiliation'})),
- ("country", "Country", urlreverse(authors_timeline, kwargs={'doc_type': doc_type, 'stats_type': 'country'})),
+ ("affiliation", "Affiliation", urlreverse(authors_timeline, kwargs={"doc_type": doc_type, "stats_type": "affiliation"})),
+ ("country", "Country", urlreverse(authors_timeline, kwargs={"doc_type": doc_type, "stats_type": "country"})),
]
return render(request, "stats/documents_timeline.html", {
@@ -349,8 +360,8 @@ def authors_timeline(request: HttpRequest, doc_type: str = 'all', stats_type: st
"objects": "authors",
"possible_docs_types": possible_docs_types,
"possible_stats_types": possible_stats_types,
- "timeline_url": urlreverse(authors_timeline, kwargs={'doc_type': doc_type, 'stats_type': stats_type}),
- "total_url": urlreverse(authors_total, kwargs={'doc_type': doc_type, 'stats_type': stats_type}),
+ "timeline_url": urlreverse(authors_timeline, kwargs={"doc_type": doc_type, "stats_type": stats_type}),
+ "total_url": urlreverse(authors_total, kwargs={"doc_type": doc_type, "stats_type": stats_type}),
"doc_type": doc_type,
"stats_type": stats_type,
"chart_data": chart_data,
From 7e304dcffc33afa943ea57b87c1714ff4345d95a Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Sat, 4 Jul 2026 04:55:03 +0000
Subject: [PATCH 126/181] Make ruff happier for more files
---
ietf/stats/tests.py | 225 ++++++++++---------
ietf/stats/views.py | 14 +-
ietf/stats/views_documents.py | 254 +++++++++++----------
ietf/stats/views_meetings.py | 401 +++++++++++++++++++---------------
4 files changed, 489 insertions(+), 405 deletions(-)
diff --git a/ietf/stats/tests.py b/ietf/stats/tests.py
index cd65fe53545..421cf20c754 100644
--- a/ietf/stats/tests.py
+++ b/ietf/stats/tests.py
@@ -2,40 +2,51 @@
import csv
import datetime
-import re
import io
import json
+import re
import factory
from django.http import Http404
-from pyquery import PyQuery
-
-import debug # pyflakes:ignore
-
from django.test import RequestFactory
from django.urls import reverse as urlreverse
from django.utils import timezone
+from pyquery import PyQuery
-from ietf.meeting.models import Meeting
-from ietf.utils.test_utils import login_testing_unauthorized, TestCase
import ietf.stats.views
-
-from ietf.doc.factories import WgDraftFactory, WgRfcFactory, DocumentAuthorFactory, RfcAuthorFactory, DocumentFactory, DocEventFactory, NewRevisionDocEventFactory
-from ietf.review.factories import ReviewRequestFactory, ReviewerSettingsFactory, ReviewAssignmentFactory
-from ietf.stats.factories import AffiliationIgnoredEndingFactory, AffiliationMainNameFactory
+from ietf.doc.factories import (
+ DocEventFactory,
+ DocumentAuthorFactory,
+ DocumentFactory,
+ NewRevisionDocEventFactory,
+ RfcAuthorFactory,
+ WgDraftFactory,
+ WgRfcFactory,
+)
from ietf.group.factories import GroupFactory, RoleFactory
-from ietf.person.factories import EmailFactory, PersonFactory
+from ietf.meeting.models import Meeting
from ietf.meeting.tests_models import MeetingFactory, RegistrationFactory
+from ietf.person.factories import EmailFactory, PersonFactory
+from ietf.review.factories import (
+ ReviewAssignmentFactory,
+ ReviewerSettingsFactory,
+ ReviewRequestFactory,
+)
+from ietf.stats.factories import (
+ AffiliationIgnoredEndingFactory,
+ AffiliationMainNameFactory,
+)
from ietf.submit.factories import SubmissionFactory
+from ietf.utils.test_utils import TestCase, login_testing_unauthorized
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())
+ 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,
+ self.assertEqual(r.status_code, 200,
msg=f"Unexpected status code {r.status_code} for URL {url}")
def test_invalid_top_n(self):
@@ -56,63 +67,63 @@ def test_document_stats(self):
group2 = GroupFactory(type_id="wg")
# Let's create some RFC and drafts with publication dates
- rfcPsGroup1 = WgRfcFactory(std_level_id='ps', group=group1)
- DocEventFactory(type='published_rfc', doc=rfcPsGroup1, time=time1960)
- rfcExpGroup1 = WgRfcFactory(std_level_id='exp', group=group1)
- DocEventFactory(type='published_rfc', doc=rfcExpGroup1, time=time1960)
- rfcInfGroup2 = WgRfcFactory(std_level_id='inf', group=group2)
- DocEventFactory(type='published_rfc', doc=rfcInfGroup2, time=timeNow)
- rfcBcpIAB1 = WgRfcFactory(std_level_id='bcp', stream_id='iab')
- DocEventFactory(type='published_rfc', doc=rfcBcpIAB1, time=time1960)
- rfcBcpIAB2 = WgRfcFactory(std_level_id='bcp', stream_id='iab')
- DocEventFactory(type='published_rfc', doc=rfcBcpIAB2, time=time1960)
- wgDraftPsGroup1 = WgDraftFactory(name='draft-ietf-' + group1.acronym + '-random-thing', intended_std_level_id='ps', group=group1)
+ rfcPsGroup1 = WgRfcFactory(std_level_id="ps", group=group1)
+ DocEventFactory(type="published_rfc", doc=rfcPsGroup1, time=time1960)
+ rfcExpGroup1 = WgRfcFactory(std_level_id="exp", group=group1)
+ DocEventFactory(type="published_rfc", doc=rfcExpGroup1, time=time1960)
+ rfcInfGroup2 = WgRfcFactory(std_level_id="inf", group=group2)
+ DocEventFactory(type="published_rfc", doc=rfcInfGroup2, time=timeNow)
+ rfcBcpIAB1 = WgRfcFactory(std_level_id="bcp", stream_id="iab")
+ DocEventFactory(type="published_rfc", doc=rfcBcpIAB1, time=time1960)
+ rfcBcpIAB2 = WgRfcFactory(std_level_id="bcp", stream_id="iab")
+ DocEventFactory(type="published_rfc", doc=rfcBcpIAB2, time=time1960)
+ wgDraftPsGroup1 = WgDraftFactory(name="draft-ietf-" + group1.acronym + "-random-thing", intended_std_level_id="ps", group=group1)
NewRevisionDocEventFactory(doc=wgDraftPsGroup1, time=time1960)
- wgDraftPsGroup2 = WgDraftFactory(name='draft-ietf-' + group2.acronym + '-random-thing', intended_std_level_id='inf', group=group2)
+ wgDraftPsGroup2 = WgDraftFactory(name="draft-ietf-" + group2.acronym + "-random-thing", intended_std_level_id="inf", group=group2)
NewRevisionDocEventFactory(doc=wgDraftPsGroup2, time=timeNow)
- draftExp = DocumentFactory(type_id='draft', intended_std_level_id='exp')
+ draftExp = DocumentFactory(type_id="draft", intended_std_level_id="exp")
NewRevisionDocEventFactory(doc=draftExp, time=timeNow)
# Let's create some authors, first get some test strings for affiliations and countries
- affiliation = factory.Faker('company').evaluate(None, None, {'locale': None})
+ affiliation = factory.Faker("company").evaluate(None, None, {"locale": None})
# Sometimes the factory adds "LLC" or some other suffix, causing problem in the tests
# below as another ", LLC" is added. Let's only take the first word of the affiliation
# up to a space or ","
- if re.sub(r',?\s*\S+\s*$', '', affiliation) != '':
- affiliation = re.sub(r',?\s*\S+\s*$', '', affiliation)
- country = factory.Faker('country').evaluate(None, None, {'locale': None})
+ if re.sub(r",?\s*\S+\s*$", "", affiliation) != "":
+ affiliation = re.sub(r",?\s*\S+\s*$", "", affiliation)
+ country = factory.Faker("country").evaluate(None, None, {"locale": None})
# Factory sometimes generates country names that are not exactly canonical
# causing problems in the tests below.
- if country == 'Korea':
- country = 'South Korea'
- elif country == 'Brunei Darussalam':
- country = 'Brunei'
- elif country == 'Cape Verde':
- country = 'Cabo Verde'
+ if country == "Korea":
+ country = "South Korea"
+ elif country == "Brunei Darussalam":
+ country = "Brunei"
+ elif country == "Cape Verde":
+ country = "Cabo Verde"
elif country == "Lao People's Democratic Republic":
- country = 'Laos'
- elif country == 'British Virgin Islands':
- country = 'Virgin Islands'
- elif country == 'Pitcairn Islands':
- country = 'Pitcairn'
-
+ country = "Laos"
+ elif country == "British Virgin Islands":
+ country = "Virgin Islands"
+ elif country == "Pitcairn Islands":
+ country = "Pitcairn"
+
# Create the various aliases ancilliary content
- AffiliationIgnoredEndingFactory(ending='llc\\.?')
- AffiliationIgnoredEndingFactory(ending='ag\\.?')
- AffiliationIgnoredEndingFactory(ending='inc\\.?')
- AffiliationIgnoredEndingFactory(ending='corp\\.?')
- AffiliationMainNameFactory(main_name='Cisco')
+ AffiliationIgnoredEndingFactory(ending="llc\\.?")
+ AffiliationIgnoredEndingFactory(ending="ag\\.?")
+ AffiliationIgnoredEndingFactory(ending="inc\\.?")
+ AffiliationIgnoredEndingFactory(ending="corp\\.?")
+ AffiliationMainNameFactory(main_name="Cisco")
RfcAuthorFactory(document=rfcPsGroup1, affiliation=affiliation, country=country)
- RfcAuthorFactory(document=rfcExpGroup1, affiliation=affiliation + ', LLC', country=country)
- RfcAuthorFactory(document=rfcExpGroup1, affiliation=factory.Faker('company'), country=factory.Faker('country'))
- DocumentAuthorFactory(document=wgDraftPsGroup1, affiliation=affiliation + ' AG', country=country)
- RfcAuthorFactory(document=rfcInfGroup2, affiliation='CiScO InC.', country=country)
- DocumentAuthorFactory(document=wgDraftPsGroup2, affiliation='CISCO corp.', country='belgique')
+ RfcAuthorFactory(document=rfcExpGroup1, affiliation=affiliation + ", LLC", country=country)
+ RfcAuthorFactory(document=rfcExpGroup1, affiliation=factory.Faker("company"), country=factory.Faker("country"))
+ DocumentAuthorFactory(document=wgDraftPsGroup1, affiliation=affiliation + " AG", country=country)
+ RfcAuthorFactory(document=rfcInfGroup2, affiliation="CiScO InC.", country=country)
+ DocumentAuthorFactory(document=wgDraftPsGroup2, affiliation="CISCO corp.", country="belgique")
DocumentAuthorFactory(document=wgDraftPsGroup2, affiliation=affiliation, country=country)
- RfcAuthorFactory(document=rfcBcpIAB1, affiliation='CiScO PTY LTD', country='UnItEd StAtEs')
- RfcAuthorFactory(document=rfcBcpIAB2, affiliation=affiliation, country='usa')
- DocumentAuthorFactory(document=draftExp, affiliation=affiliation + ',inc', country='U.S.A.')
+ RfcAuthorFactory(document=rfcBcpIAB1, affiliation="CiScO PTY LTD", country="UnItEd StAtEs")
+ RfcAuthorFactory(document=rfcBcpIAB2, affiliation=affiliation, country="usa")
+ DocumentAuthorFactory(document=draftExp, affiliation=affiliation + ",inc", country="U.S.A.")
# Test#1 the documents specific statistics: for RFC about the level
r = self.client.get(urlreverse(ietf.stats.views_documents.documents_timeline, kwargs={"doc_type": "rfc", "stats_type": "level"}))
@@ -127,13 +138,13 @@ def test_document_stats(self):
any(
ds["label"] == "inf" and ds["data"] == [0, 1]
for ds in chart_data["datasets"]
- )
+ ),
)
self.assertTrue(
any(
ds["label"] == "bcp" and ds["data"] == [2, 0]
for ds in chart_data["datasets"]
- )
+ ),
)
# Test#2 the documents specific statistics: for RFC about the WG
@@ -148,7 +159,7 @@ def test_document_stats(self):
any(
ds["label"] == group1.name and ds["data"] == [2, 0]
for ds in chart_data["datasets"]
- )
+ ),
)
# Test#3 the documents specific statistics: for drafts about the streams
@@ -163,13 +174,13 @@ def test_document_stats(self):
any(
ds["label"] == "IETF" and ds["data"] == [2]
for ds in chart_data["datasets"]
- )
+ ),
)
self.assertTrue(
any(
ds["label"] == "Unspecified" and ds["data"] == [1]
for ds in chart_data["datasets"]
- )
+ ),
)
# Test#4 the authors specific statistics: for all docs about the countries
@@ -184,13 +195,13 @@ def test_document_stats(self):
any(
ds["label"] == "United States of America" and ds["data"] == [2, 1]
for ds in chart_data["datasets"]
- )
+ ),
)
self.assertTrue(
any(
ds["label"] == "Belgium" and ds["data"] == [0, 1]
for ds in chart_data["datasets"]
- )
+ ),
)
# Test#5 the authors specific statistics: for all all rfcs about the affiliation
@@ -205,19 +216,19 @@ def test_document_stats(self):
any(
ds["label"].casefold() == affiliation.casefold() and ds["data"] == [3, 0]
for ds in chart_data["datasets"]
- )
+ ),
)
self.assertTrue(
any(
ds["label"] == "Cisco" and ds["data"] == [1, 1]
for ds in chart_data["datasets"]
- )
+ ),
)
self.assertTrue(
any(
ds["label"] == "Other" and ds["data"] == [0, 0]
for ds in chart_data["datasets"]
- )
+ ),
)
# Test#6 the authors specific statistics: for all WG drafts about the country
@@ -228,22 +239,22 @@ def test_document_stats(self):
pq = PyQuery(r.content)
chart_data = json.loads(pq.find("script#chart_data").text())
self.assertTrue(chart_data["labels"] == [yearNow])
- # Test sometimes failing below with the factory country name being different from the country name in the chart data,
- # even though they should be the same country.
- # Using casefold to make the comparison more robust, as the country names in the chart data are title-cased
+ # Test sometimes failing below with the factory country name being different from the country name in the chart data,
+ # even though they should be the same country.
+ # Using casefold to make the comparison more robust, as the country names in the chart data are title-cased
# while the factory can return them in different cases.
self.assertTrue(
any(
ds["label"].casefold() == country.casefold() and ds["data"] == [2]
for ds in chart_data["datasets"]
),
- msg=f"Country '{country}' not found in chart data labels: {chart_data['datasets']}"
+ msg=f"Country '{country}' not found in chart data labels: {chart_data['datasets']}",
)
self.assertTrue(
any(
ds["label"] == "Belgium" and ds["data"] == [1]
for ds in chart_data["datasets"]
- )
+ ),
)
# Test#7 the authors specific statistics global
@@ -253,11 +264,11 @@ def test_document_stats(self):
# Extract the JSON embedded in the response
pq = PyQuery(r.content)
chart_data = json.loads(pq.find("script#chart_data").text())
- self.assertTrue('Belgium' in chart_data["labels"])
- self.assertTrue('United States of America' in chart_data["labels"])
+ self.assertTrue("Belgium" in chart_data["labels"])
+ self.assertTrue("United States of America" in chart_data["labels"])
self.assertTrue(country in chart_data["labels"])
- USA_index = chart_data["labels"].index('United States of America')
- # Let's check whether USA has indeed 1
+ USA_index = chart_data["labels"].index("United States of America")
+ # Let's check whether USA has indeed 1
self.assertTrue(chart_data["datasets"][0]["data"][USA_index] == 1)
# Test#8 the documents specific statistics global
@@ -268,23 +279,23 @@ def test_document_stats(self):
pq = PyQuery(r.content)
chart_data = json.loads(pq.find("script#chart_data").text())
self.assertTrue(group1.name in chart_data["labels"])
- individual_index = chart_data["labels"].index('Individual submissions')
- # Let's check whether USA has indeed 1
+ individual_index = chart_data["labels"].index("Individual submissions")
+ # Let's check whether USA has indeed 1
self.assertTrue(chart_data["datasets"][0]["data"][individual_index] == 1)
def test_meeting_stats(self):
- 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)
+ 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)
# Create the various aliases ancilliary content
- AffiliationIgnoredEndingFactory(ending='llc\\.?')
- AffiliationIgnoredEndingFactory(ending='ltd\\.?')
+ AffiliationIgnoredEndingFactory(ending="llc\\.?")
+ AffiliationIgnoredEndingFactory(ending="ltd\\.?")
# Test the meeting specific statitistics per affiliation and per country
r = self.client.get(urlreverse(ietf.stats.views_meetings.meeting_stats, kwargs={"meeting_number": "124", "stats_type": "affiliation"}))
@@ -318,7 +329,7 @@ def test_meeting_stats(self):
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.meetings_timeline, kwargs={"stats_type": "total"}))
@@ -334,7 +345,7 @@ def test_meeting_stats_for_bad_meeting(self):
urlreverse(
"ietf.stats.views_meetings.meeting_stats",
kwargs={"meeting_number": 676767, "stats_type": stats_type},
- )
+ ),
)
self.assertEqual(r.status_code, 404)
@@ -359,11 +370,11 @@ def test_known_country_list(self):
def test_review_stats(self):
reviewer = PersonFactory()
- review_req = ReviewRequestFactory(state_id='assigned')
- ReviewAssignmentFactory(review_request=review_req, state_id='assigned', reviewer=reviewer.email_set.first())
- RoleFactory(group=review_req.team,name_id='reviewer',person=reviewer)
+ review_req = ReviewRequestFactory(state_id="assigned")
+ ReviewAssignmentFactory(review_request=review_req, state_id="assigned", reviewer=reviewer.email_set.first())
+ RoleFactory(group=review_req.team,name_id="reviewer",person=reviewer)
ReviewerSettingsFactory(team=review_req.team, person=reviewer)
- PersonFactory(user__username='plain')
+ PersonFactory(user__username="plain")
# check redirect
url = urlreverse(ietf.stats.views_reviews.review_stats)
@@ -394,34 +405,34 @@ def test_review_stats(self):
# check stacked chart
url = urlreverse(ietf.stats.views_reviews.review_stats, kwargs={ "stats_type": "time" })
- url += "?team={}".format(review_req.team.acronym)
+ url += f"?team={review_req.team.acronym}"
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
- stacked_data = json.loads(r.context['data'])
+ stacked_data = json.loads(r.context["data"])
# Ignore the timestamp elements, just check that the data is correct
self.assertEqual(len(stacked_data), 2)
- self.assertEqual(stacked_data[0]['label'], 'in time')
- self.assertEqual(stacked_data[0]['color'], '#3d22b3')
- self.assertEqual(stacked_data[0]['data'], [[stacked_data[0]['data'][0][0], 0]])
- self.assertEqual(stacked_data[1]['label'], 'late')
- self.assertEqual(stacked_data[1]['color'], '#b42222')
- self.assertEqual(stacked_data[1]['data'], [[stacked_data[0]['data'][0][0], 0]])
+ self.assertEqual(stacked_data[0]["label"], "in time")
+ self.assertEqual(stacked_data[0]["color"], "#3d22b3")
+ self.assertEqual(stacked_data[0]["data"], [[stacked_data[0]["data"][0][0], 0]])
+ self.assertEqual(stacked_data[1]["label"], "late")
+ self.assertEqual(stacked_data[1]["color"], "#b42222")
+ self.assertEqual(stacked_data[1]["data"], [[stacked_data[0]["data"][0][0], 0]])
q = PyQuery(r.content)
- self.assertTrue(q('#stats-time-graph'))
+ self.assertTrue(q("#stats-time-graph"))
# check non-stacked chart
url = urlreverse(ietf.stats.views_reviews.review_stats, kwargs={ "stats_type": "time" })
- url += "?team={}".format(review_req.team.acronym)
+ url += f"?team={review_req.team.acronym}"
url += "&completion=not_completed"
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
- non_stacked_data = json.loads(r.context['data'])
+ non_stacked_data = json.loads(r.context["data"])
# Ignore the timestamp elements, just check that the data is correct
self.assertEqual(len(non_stacked_data), 1)
- self.assertEqual(non_stacked_data[0]['color'], '#3d22b3')
- self.assertEqual(non_stacked_data[0]['data'], [[non_stacked_data[0]['data'][0][0], 0]])
+ self.assertEqual(non_stacked_data[0]["color"], "#3d22b3")
+ self.assertEqual(non_stacked_data[0]["data"], [[non_stacked_data[0]["data"][0][0], 0]])
q = PyQuery(r.content)
- self.assertTrue(q('#stats-time-graph'))
+ self.assertTrue(q("#stats-time-graph"))
# check reviewer level
url = urlreverse(ietf.stats.views_reviews.review_stats, kwargs={ "stats_type": "completion", "acronym": review_req.team.acronym })
diff --git a/ietf/stats/views.py b/ietf/stats/views.py
index d34b0a9ca8b..6f31160b8b4 100644
--- a/ietf/stats/views.py
+++ b/ietf/stats/views.py
@@ -1,5 +1,4 @@
# Copyright The IETF Trust 2016-2026, All Rights Reserved
-# -*- coding: utf-8 -*-
import csv
@@ -9,17 +8,16 @@
from django.shortcuts import render
from django.urls import reverse as urlreverse
-import debug # pyflakes:ignore
-
-from ietf.name.models import CountryName
from ietf.ietfauth.utils import role_required
from ietf.meeting.helpers import get_current_ietf_meeting_num
+from ietf.name.models import CountryName
+
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
+ "current_meeting": current_meeting,
})
def known_countries_list(request):
@@ -38,7 +36,7 @@ def known_countries_list(request):
def annual_report_inputs(request, year=None):
if year is None and "year" in request.GET:
return HttpResponseRedirect(
- urlreverse("ietf.stats.views.annual_report_inputs", kwargs={"year": request.GET["year"]})
+ urlreverse("ietf.stats.views.annual_report_inputs", kwargs={"year": request.GET["year"]}),
)
year = int(year) if year else datetime.date.today().year - 1
@@ -61,8 +59,8 @@ def annual_report_inputs(request, year=None):
draft_count = len(set(
NewRevisionDocEvent.objects.filter(
- doc__type_id="draft", time__year=year
- ).values_list("doc__name", flat=True)
+ doc__type_id="draft", time__year=year,
+ ).values_list("doc__name", flat=True),
))
return render(request, "stats/annual_report_inputs.html", {
diff --git a/ietf/stats/views_documents.py b/ietf/stats/views_documents.py
index 38d054fd59d..da766d5e4de 100644
--- a/ietf/stats/views_documents.py
+++ b/ietf/stats/views_documents.py
@@ -1,27 +1,24 @@
# Copyright The IETF Trust 2016-2026, All Rights Reserved
-# -*- coding: utf-8 -*-
-from typing import Tuple, List, Dict, Any
+from collections import defaultdict
+from typing import Any
from django.conf import settings
+from django.core.cache import cache
from django.db.models import Count, Q
from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse as urlreverse
-from django.core.cache import cache
-
-import debug # pyflakes:ignore
-
-from collections import defaultdict
from ietf.doc.models import Document
-from ietf.stats.utils import color_from_hash, check_top_n_choice, get_top_n_choices
+from ietf.stats.utils import check_top_n_choice, color_from_hash, get_top_n_choices
+
def get_total_data_for_documents(
- doc_type: str = 'rfc',
- group_by: str = 'level',
+ doc_type: str = "rfc",
+ group_by: str = "level",
top_n: int = 20,
-) -> Dict[str, Any]:
+) -> dict[str, Any]:
"""Get aggregated document statistics grouped by the specified field.
Args:
@@ -31,13 +28,14 @@ def get_total_data_for_documents(
Returns:
Chart.js compatible data dictionary with labels and datasets.
+
"""
# Build a dynamic query set filter
filters = Q()
- if doc_type == 'all':
- filters &= Q(type_id__in=['draft', 'rfc'])
- elif doc_type == 'wg-draft':
- filters &= Q(type_id='draft')
+ if doc_type == "all":
+ filters &= Q(type_id__in=["draft", "rfc"])
+ elif doc_type == "wg-draft":
+ filters &= Q(type_id="draft")
filters &= Q(document__group__type_id="wg")
else:
filters &= Q(type_id=doc_type)
@@ -45,37 +43,39 @@ def get_total_data_for_documents(
Document.objects
.filter(filters)
.values(group_by)
- .annotate(document_count=Count('id', distinct=True))
- .order_by('-document_count')
+ .annotate(document_count=Count("id", distinct=True))
+ .order_by("-document_count")
)
# Convert queryset to dictionary, aggregating by group
- group_count_dict: Dict[str, int] = {}
- for group, count in queryset.values_list(group_by, 'document_count'):
- if not group or group == '':
- group = 'Unspecified'
+ group_count_dict: dict[str, int] = {}
+ for group, count in queryset.values_list(group_by, "document_count"):
+ if not group or group == "":
+ group = "Unspecified"
group_count_dict[group] = group_count_dict.get(group, 0) + count
sorted_groups = sorted(group_count_dict.items(), key=lambda x: x[1], reverse=True)
top_groups = sorted_groups[:top_n]
other_count = sum(count for _, count in sorted_groups[top_n:])
if other_count > 0:
- top_groups.append(('Other', other_count))
-
- labels: Tuple[str, ...] = tuple(label for label, _ in top_groups)
- data: Tuple[int, ...] = tuple(count for _, count in top_groups)
- chart_data: Dict[str, Any] = {
- 'labels': labels,
- 'datasets': [{
- 'data': data,
- 'backgroundColor': [color_from_hash(label) if label else '#202020' for label in labels],
- 'borderColor': 'black',
- 'borderWidth': 1,
+ top_groups.append(("Other", other_count))
+
+ labels: tuple[str, ...] = tuple(label for label, _ in top_groups)
+ data: tuple[int, ...] = tuple(count for _, count in top_groups)
+ chart_data: dict[str, Any] = {
+ "labels": labels,
+ "datasets": [{
+ "data": data,
+ "backgroundColor": [color_from_hash(label)
+ if label else "#202020"
+ for label in labels],
+ "borderColor": "black",
+ "borderWidth": 1,
}],
}
return chart_data
-def documents_total(request: Any, doc_type: str = 'rfc', stats_type: str = 'level') -> Any:
+def documents_total(request: Any, doc_type: str = "rfc", stats_type: str = "level") -> Any:
"""Render document statistics page with pie chart aggregations.
Args:
@@ -85,42 +85,51 @@ def documents_total(request: Any, doc_type: str = 'rfc', stats_type: str = 'leve
Returns:
Rendered response for the documents_total template.
+
"""
# Query parameters (from ?key=value)
try:
- top_n = max(1, min(int(request.GET.get('top', '10')), 100))
+ top_n = max(1, min(int(request.GET.get("top", "10")), 100))
except (ValueError, TypeError):
top_n = 10
# Check the top-n value against the allowed choices
if not check_top_n_choice(top_n):
- return render(request, "stats/error.html", {"message": f"Invalid top_n choice: {top_n}. Valid choices are: {get_top_n_choices()}"})
-
-
- if stats_type == 'stream':
- chart_data = get_total_data_for_documents(doc_type, 'stream__name', top_n)
- elif stats_type == 'level' and doc_type == 'draft':
- chart_data = get_total_data_for_documents(doc_type, 'intended_std_level_id', top_n)
- elif stats_type == 'level' and doc_type == 'rfc':
- chart_data = get_total_data_for_documents(doc_type, 'std_level_id', top_n)
- elif stats_type == 'wg':
- chart_data = get_total_data_for_documents(doc_type, 'group__name', top_n)
+ return render(request,
+ "stats/error.html",
+ {"message": f"Invalid top_n choice: {top_n}. Valid choices are: {get_top_n_choices()}"})
+
+
+ if stats_type == "stream":
+ chart_data = get_total_data_for_documents(doc_type, "stream__name", top_n)
+ elif stats_type == "level" and doc_type == "draft":
+ chart_data = get_total_data_for_documents(doc_type, "intended_std_level_id", top_n)
+ elif stats_type == "level" and doc_type == "rfc":
+ chart_data = get_total_data_for_documents(doc_type, "std_level_id", top_n)
+ elif stats_type == "wg":
+ chart_data = get_total_data_for_documents(doc_type, "group__name", top_n)
else:
return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
# Prepare the list of choice buttons for the template
possible_docs_types = [
- ("draft", "Drafts", urlreverse(documents_total, kwargs={'doc_type': 'draft', 'stats_type': stats_type})),
- ("rfc", "RFCs", urlreverse(documents_total, kwargs={'doc_type': 'rfc', 'stats_type': stats_type})),
+ ("draft", "Drafts", urlreverse(documents_total,
+ kwargs={"doc_type": "draft", "stats_type": stats_type})),
+ ("rfc", "RFCs", urlreverse(documents_total,
+ kwargs={"doc_type": "rfc", "stats_type": stats_type})),
]
possible_stats_types = [
- ("stream", "Streams", urlreverse(documents_total, kwargs={'doc_type': doc_type, 'stats_type': 'stream'})),
- ("wg", "Working Groups", urlreverse(documents_total, kwargs={'doc_type': doc_type, 'stats_type': 'wg'})),
+ ("stream", "Streams", urlreverse(documents_total,
+ kwargs={"doc_type": doc_type, "stats_type": "stream"})),
+ ("wg", "Working Groups", urlreverse(documents_total,
+ kwargs={"doc_type": doc_type, "stats_type": "wg"})),
]
- if doc_type == 'draft':
- possible_stats_types.append(("level", "Intended Status", urlreverse(documents_total, kwargs={'doc_type': doc_type, 'stats_type': 'level'})))
- elif doc_type == 'rfc':
- possible_stats_types.append(("level", "Category", urlreverse(documents_total, kwargs={'doc_type': doc_type, 'stats_type': 'level'})))
+ if doc_type == "draft":
+ possible_stats_types.append(("level", "Intended Status", urlreverse(documents_total,
+ kwargs={"doc_type": doc_type, "stats_type": "level"})))
+ elif doc_type == "rfc":
+ possible_stats_types.append(("level", "Category", urlreverse(documents_total,
+ kwargs={"doc_type": doc_type, "stats_type": "level"})))
return render(request, "stats/documents_total.html", {
"top_n": top_n,
@@ -128,18 +137,18 @@ def documents_total(request: Any, doc_type: str = 'rfc', stats_type: str = 'leve
"objects": "documents",
"possible_docs_types": possible_docs_types,
"possible_stats_types": possible_stats_types,
- "timeline_url": urlreverse(documents_timeline, kwargs={'doc_type': doc_type, 'stats_type': stats_type}),
- "total_url": urlreverse(documents_total, kwargs={'doc_type': doc_type, 'stats_type': stats_type}),
+ "timeline_url": urlreverse(documents_timeline, kwargs={"doc_type": doc_type, "stats_type": stats_type}),
+ "total_url": urlreverse(documents_total, kwargs={"doc_type": doc_type, "stats_type": stats_type}),
"doc_type": doc_type,
"stats_type": stats_type,
"chart_data": chart_data,
})
def get_timeline_data_for_documents(
- doc_type: str = 'rfc',
- group_by: str = 'stream__name',
+ doc_type: str = "rfc",
+ group_by: str = "stream__name",
top_n: int = 10,
-) -> Tuple[List[int], List[Dict[str, Any]]]:
+) -> tuple[list[int], list[dict[str, Any]]]:
"""Get timeline data for documents grouped by field over years.
Args:
@@ -149,22 +158,23 @@ def get_timeline_data_for_documents(
Returns:
Tuple of (sorted_years, datasets) for Chart.js timeline chart.
+
"""
- cache_key = f'stats:get_timeline_data_for_documents:{doc_type}-{group_by}'
+ cache_key = f"stats:get_timeline_data_for_documents:{doc_type}-{group_by}"
result = cache.get(cache_key, None)
-
+
# Initialize variables with proper types
years_set: list[int]
- documents_totals: Dict[str, int]
- data_map: Dict[int, Dict[str, int]]
-
+ documents_totals: dict[str, int]
+ data_map: dict[int, dict[str, int]]
+
if result is not None:
years_set, documents_totals, data_map = result
else:
- if doc_type != 'all': # Filter by specific document type
+ if doc_type != "all": # Filter by specific document type
queryset = Document.objects.filter(type_id=doc_type)
else: # doc_type == 'all', include both drafts and RFCs (and this option is no more used in urls.py though)
- queryset = Document.objects.filter(type_id__in=['draft', 'rfc'])
+ queryset = Document.objects.filter(type_id__in=["draft", "rfc"])
# ── Step 1: Collect all years and document totals ──
years_set_temp: set[int] = set()
@@ -175,14 +185,14 @@ def get_timeline_data_for_documents(
if not row.pub_date():
continue
year = row.pub_date().year
- if group_by == 'stream__name':
- group = row.stream.name if row.stream else 'Unspecified'
- elif group_by == 'group__name':
- group = row.group.name if row.group else 'Unspecified'
+ if group_by == "stream__name":
+ group = row.stream.name if row.stream else "Unspecified"
+ elif group_by == "group__name":
+ group = row.group.name if row.group else "Unspecified"
else:
group = getattr(row, group_by, None)
if not group:
- group = 'Unspecified'
+ group = "Unspecified"
years_set_temp.add(year)
documents_totals[group] += 1
data_map[year][group] = data_map[year].get(group, 0) + 1
@@ -198,10 +208,10 @@ def get_timeline_data_for_documents(
top_groups = sorted(
documents_totals.keys(),
key=lambda c: documents_totals[c],
- reverse=True
+ reverse=True,
)[:top_n]
non_top_groups = set(documents_totals.keys()) - set(top_groups)
- other_totals: Dict[int, int] = defaultdict(int)
+ other_totals: dict[int, int] = defaultdict(int)
other_bin_is_empty = True
for y in years_set:
for g in non_top_groups:
@@ -211,39 +221,39 @@ def get_timeline_data_for_documents(
other_bin_is_empty = False
# ── Step 3: Build Chart.js datasets ──
- datasets: List[Dict[str, Any]] = []
+ datasets: list[dict[str, Any]] = []
for group in top_groups:
color = color_from_hash(group)
datasets.append({
- 'label': group,
- 'data': [data_map[year].get(group, 0) for year in years_set],
- 'borderColor': color,
- 'backgroundColor': color + '99', # 60% opacity fill
- 'fill': False,
- 'tension': 0.0,
- 'pointColor': color,
- 'pointBackgroundColor': color,
- 'pointRadius': 4,
- 'pointHoverRadius': 6,
- 'borderWidth': 2,
+ "label": group,
+ "data": [data_map[year].get(group, 0) for year in years_set],
+ "borderColor": color,
+ "backgroundColor": color + "99", # 60% opacity fill
+ "fill": False,
+ "tension": 0.0,
+ "pointColor": color,
+ "pointBackgroundColor": color,
+ "pointRadius": 4,
+ "pointHoverRadius": 6,
+ "borderWidth": 2,
})
if not other_bin_is_empty:
datasets.append({
- 'label': 'Other',
- 'data': [other_totals.get(year, 0) for year in years_set],
- 'borderColor': 'black',
- 'fill': False,
- 'tension': 0.0,
- 'pointColor': 'black',
- 'pointBackgroundColor': 'black',
- 'pointRadius': 4,
- 'pointHoverRadius': 6,
- 'borderWidth': 2,
+ "label": "Other",
+ "data": [other_totals.get(year, 0) for year in years_set],
+ "borderColor": "black",
+ "fill": False,
+ "tension": 0.0,
+ "pointColor": "black",
+ "pointBackgroundColor": "black",
+ "pointRadius": 4,
+ "pointHoverRadius": 6,
+ "borderWidth": 2,
})
return years_set, datasets
-def documents_timeline(request: Any, doc_type: str = 'rfc', stats_type: str = 'level') -> Any:
+def documents_timeline(request: Any, doc_type: str = "rfc", stats_type: str = "level") -> Any:
"""Render the documents timeline page with document statistics over time.
Args:
@@ -253,45 +263,54 @@ def documents_timeline(request: Any, doc_type: str = 'rfc', stats_type: str = 'l
Returns:
Rendered response for the documents timeline template.
+
"""
# Query parameters (from ?key=value)
try:
- top_n = max(1, min(int(request.GET.get('top', '10')), 100))
+ top_n = max(1, min(int(request.GET.get("top", "10")), 100))
except (ValueError, TypeError):
top_n = 10
# Check the top-n value against the allowed choices
if not check_top_n_choice(top_n):
- return render(request, "stats/error.html", {"message": f"Invalid top_n choice: {top_n}. Valid choices are: {get_top_n_choices()}"})
-
- if stats_type == 'stream':
- total_labels, total_data_sets = get_timeline_data_for_documents(doc_type, 'stream__name', top_n)
- elif stats_type == 'level' and doc_type == 'draft':
- total_labels, total_data_sets = get_timeline_data_for_documents(doc_type, 'intended_std_level_id', top_n)
- elif stats_type == 'level' and doc_type == 'rfc':
- total_labels, total_data_sets = get_timeline_data_for_documents(doc_type, 'std_level_id', top_n)
- elif stats_type == 'wg':
- total_labels, total_data_sets = get_timeline_data_for_documents(doc_type, 'group__name', top_n)
+ return render(request,
+ "stats/error.html",
+ {"message": f"Invalid top_n choice: {top_n}. Valid choices are: {get_top_n_choices()}"})
+
+ if stats_type == "stream":
+ total_labels, total_data_sets = get_timeline_data_for_documents(doc_type, "stream__name", top_n)
+ elif stats_type == "level" and doc_type == "draft":
+ total_labels, total_data_sets = get_timeline_data_for_documents(doc_type, "intended_std_level_id", top_n)
+ elif stats_type == "level" and doc_type == "rfc":
+ total_labels, total_data_sets = get_timeline_data_for_documents(doc_type, "std_level_id", top_n)
+ elif stats_type == "wg":
+ total_labels, total_data_sets = get_timeline_data_for_documents(doc_type, "group__name", top_n)
else:
return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
chart_data = {
- 'labels': total_labels,
- 'datasets': total_data_sets,
+ "labels": total_labels,
+ "datasets": total_data_sets,
}
# Prepare the list of choice buttons for the template
possible_docs_types = [
- ("draft", "Drafts", urlreverse(documents_timeline, kwargs={'doc_type': 'draft', 'stats_type': stats_type})),
- ("rfc", "RFC", urlreverse(documents_timeline, kwargs={'doc_type': 'rfc', 'stats_type': stats_type})),
+ ("draft", "Drafts", urlreverse(documents_timeline,
+ kwargs={"doc_type": "draft", "stats_type": stats_type})),
+ ("rfc", "RFC", urlreverse(documents_timeline,
+ kwargs={"doc_type": "rfc", "stats_type": stats_type})),
]
possible_stats_types = [
- ("stream", "Streams", urlreverse(documents_timeline, kwargs={'doc_type': doc_type, 'stats_type': 'stream'})),
- ("wg", "Working Groups", urlreverse(documents_timeline, kwargs={'doc_type': doc_type, 'stats_type': 'wg'})),
+ ("stream", "Streams", urlreverse(documents_timeline,
+ kwargs={"doc_type": doc_type, "stats_type": "stream"})),
+ ("wg", "Working Groups", urlreverse(documents_timeline,
+ kwargs={"doc_type": doc_type, "stats_type": "wg"})),
]
- if doc_type == 'draft':
- possible_stats_types.append(("level", "Intended Status", urlreverse(documents_timeline, kwargs={'doc_type': doc_type, 'stats_type': 'level'})))
- elif doc_type == 'rfc':
- possible_stats_types.append(("level", "Category", urlreverse(documents_timeline, kwargs={'doc_type': doc_type, 'stats_type': 'level'})))
+ if doc_type == "draft":
+ possible_stats_types.append(("level", "Intended Status", urlreverse(documents_timeline,
+ kwargs={"doc_type": doc_type, "stats_type": "level"})))
+ elif doc_type == "rfc":
+ possible_stats_types.append(("level", "Category", urlreverse(documents_timeline,
+ kwargs={"doc_type": doc_type, "stats_type": "level"})))
return render(request, "stats/documents_timeline.html", {
"top_n": top_n,
@@ -299,7 +318,8 @@ def documents_timeline(request: Any, doc_type: str = 'rfc', stats_type: str = 'l
"objects": "documents",
"possible_docs_types": possible_docs_types,
"possible_stats_types": possible_stats_types,
- "total_url": urlreverse(documents_total, kwargs={'doc_type': doc_type, 'stats_type': stats_type}),
+ "total_url": urlreverse(documents_total,
+ kwargs={"doc_type": doc_type, "stats_type": stats_type}),
"doc_type": doc_type,
"stats_type": stats_type,
"chart_data": chart_data,
diff --git a/ietf/stats/views_meetings.py b/ietf/stats/views_meetings.py
index 1e9c98eb69e..4a917569bac 100644
--- a/ietf/stats/views_meetings.py
+++ b/ietf/stats/views_meetings.py
@@ -1,33 +1,36 @@
# Copyright The IETF Trust 2016-2026, All Rights Reserved
-# -*- coding: utf-8 -*-
-from typing import Optional, Tuple, List, Dict, Any
from collections import defaultdict
-
-import debug # pyflakes:ignore
+from typing import Any
from django.conf import settings
+from django.core.cache import cache
from django.db.models import Count
from django.http import HttpResponseRedirect
-from django.shortcuts import render, get_object_or_404
+from django.shortcuts import get_object_or_404, render
from django.urls import reverse as urlreverse
-from django.core.cache import cache
-from ietf.meeting.models import Registration, Meeting
-from ietf.stats.utils import color_from_hash, get_aliased_affiliations, get_aliased_countries, check_top_n_choice, get_top_n_choices
from ietf.meeting.helpers import get_current_ietf_meeting_num
+from ietf.meeting.models import Meeting, Registration
+from ietf.stats.utils import (
+ check_top_n_choice,
+ color_from_hash,
+ get_aliased_affiliations,
+ get_aliased_countries,
+ get_top_n_choices,
+)
# Constants
FIRST_MEETING_WITH_REGISTRATION_DATA = 72
def _build_timeline_datasets(
- top_items: List[str],
- data_map: Dict[str, Dict[str, int]],
- sorted_meetings: List[str],
- other_totals: Dict[str, int],
+ top_items: list[str],
+ data_map: dict[str, dict[str, int]],
+ sorted_meetings: list[str],
+ other_totals: dict[str, int],
include_background_color: bool = False,
-) -> List[Dict[str, Any]]:
+) -> list[dict[str, Any]]:
"""Build Chart.js datasets for timeline charts.
Args:
@@ -39,49 +42,50 @@ def _build_timeline_datasets(
Returns:
List of Chart.js dataset dictionaries.
+
"""
- datasets: List[Dict[str, Any]] = []
+ datasets: list[dict[str, Any]] = []
for item in top_items:
color = color_from_hash(item)
dataset = {
- 'label': item,
- 'data': [data_map[item].get(m, 0) for m in sorted_meetings],
- 'borderColor': color,
- 'fill': bool(include_background_color),
- 'tension': 0.0 if include_background_color else 0.3,
- 'pointColor': color,
- 'pointBackgroundColor': color,
- 'pointRadius': 4,
- 'pointHoverRadius': 6,
- 'borderWidth': 2,
+ "label": item,
+ "data": [data_map[item].get(m, 0) for m in sorted_meetings],
+ "borderColor": color,
+ "fill": bool(include_background_color),
+ "tension": 0.0 if include_background_color else 0.3,
+ "pointColor": color,
+ "pointBackgroundColor": color,
+ "pointRadius": 4,
+ "pointHoverRadius": 6,
+ "borderWidth": 2,
}
if include_background_color:
- dataset['backgroundColor'] = color + '99'
+ dataset["backgroundColor"] = color + "99"
datasets.append(dataset)
# Add "Other" category
datasets.append({
- 'label': 'Other',
- 'data': [other_totals.get(m, 0) for m in sorted_meetings],
- 'borderColor': 'black',
- 'fill': bool(include_background_color),
- 'tension': 0.0 if include_background_color else 0.3,
- 'pointColor': 'black',
- 'pointBackgroundColor': 'black',
- 'pointRadius': 4,
- 'pointHoverRadius': 6,
- 'borderWidth': 2,
+ "label": "Other",
+ "data": [other_totals.get(m, 0) for m in sorted_meetings],
+ "borderColor": "black",
+ "fill": bool(include_background_color),
+ "tension": 0.0 if include_background_color else 0.3,
+ "pointColor": "black",
+ "pointBackgroundColor": "black",
+ "pointRadius": 4,
+ "pointHoverRadius": 6,
+ "borderWidth": 2,
})
if include_background_color:
- datasets[-1]['backgroundColor'] = '#00000099'
+ datasets[-1]["backgroundColor"] = "#00000099"
return datasets
def _build_pie_chart_data(
- items_with_counts: List[Tuple[str, int]],
+ items_with_counts: list[tuple[str, int]],
top_n: int = 20,
-) -> Tuple[List[str], List[int], int]:
+) -> tuple[list[str], list[int], int]:
"""Build pie chart data from sorted items.
Args:
@@ -90,9 +94,10 @@ def _build_pie_chart_data(
Returns:
Tuple of (labels, data, total).
+
"""
- labels: List[str] = []
- data: List[int] = []
+ labels: list[str] = []
+ data: list[int] = []
total = 0
for item, count in items_with_counts[:top_n]:
@@ -106,22 +111,25 @@ def _build_pie_chart_data(
total += count
if other_total > 0:
- labels.append('Other')
+ labels.append("Other")
data.append(other_total)
return labels, data, total
-def get_affiliation_data_for_meetings(attendance_type: Optional[str] = None, top_n: int = 20) -> Tuple[List[str], List[Dict[str, Any]]]:
+def get_affiliation_data_for_meetings(attendance_type: str | None = None,
+ top_n: int = 20) -> tuple[list[str], list[dict[str, Any]]]:
"""Get affiliation participation data for meetings timeline chart.
Args:
attendance_type: Optional filter for attendance type (e.g., 'onsite').
+ top_n: Number of top items to return.
Returns:
Tuple of (sorted_meetings, datasets) for Chart.js.
+
"""
- cache_key = f'stats:get_affiliation_data_for_meetings:{attendance_type}-{top_n}'
+ cache_key = f"stats:get_affiliation_data_for_meetings:{attendance_type}-{top_n}"
sorted_meetings, datasets = cache.get(cache_key, (None, None))
if (sorted_meetings, datasets) == (None, None):
@@ -130,44 +138,46 @@ def get_affiliation_data_for_meetings(attendance_type: Optional[str] = None, top
base_registrations = Registration.objects.filter(tickets__attendance_type=attendance_type)
else:
base_registrations = Registration.objects.all()
- registrations = base_registrations.values('affiliation', 'meeting__number')
-
+ registrations = base_registrations.values("affiliation", "meeting__number")
+
# Prepare affiliation data, applying canonicalization and aliasing
- alias_map = get_aliased_affiliations(affiliation for affiliation in registrations.values_list('affiliation', flat=True))
+ alias_map = get_aliased_affiliations(affiliation
+ for affiliation
+ in registrations.values_list("affiliation", flat=True))
# Count per canonicalized affiliation
- organization: Dict[str, int] = {}
+ organization: dict[str, int] = {}
meetings_set: set[str] = set()
- org_totals: Dict[str, int] = defaultdict(int)
- data_map: Dict[str, Dict[str, int]] = defaultdict(dict) # {org: {meeting: count}}
-
+ org_totals: dict[str, int] = defaultdict(int)
+ data_map: dict[str, dict[str, int]] = defaultdict(dict) # {org: {meeting: count}}
+
for reg in registrations:
- meeting = reg['meeting__number']
+ meeting = reg["meeting__number"]
meetings_set.add(meeting)
- if not reg['affiliation'] or not reg['affiliation'].strip():
- affiliation = 'Unspecified'
- else:
- affiliation = alias_map.get(reg['affiliation'], reg['affiliation'])
+ if not reg["affiliation"] or not reg["affiliation"].strip():
+ affiliation = "Unspecified"
+ else:
+ affiliation = alias_map.get(reg["affiliation"], reg["affiliation"])
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
+ reverse=True,
)[:top_n]
non_top_orgs = set(org_totals.keys()) - set(top_orgs)
- other_totals: Dict[str, int] = defaultdict(int)
+ other_totals: dict[str, int] = 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 = _build_timeline_datasets(top_orgs, data_map, sorted_meetings, other_totals)
cache.set(
@@ -178,16 +188,19 @@ def get_affiliation_data_for_meetings(attendance_type: Optional[str] = None, top
return sorted_meetings, datasets
-def get_country_data_for_meetings(attendance_type: Optional[str] = None, top_n: int = 20) -> Tuple[List[str], List[Dict[str, Any]]]:
+def get_country_data_for_meetings(attendance_type: str | None = None,
+ top_n: int = 20) -> tuple[list[str], list[dict[str, Any]]]:
"""Get country participation data for meetings timeline chart.
Args:
attendance_type: Optional filter for attendance type (e.g., 'onsite').
+ top_n: Number of top items to return.
Returns:
Tuple of (sorted_meetings, datasets) for Chart.js.
+
"""
- cache_key = f'stats:get_country_data_for_meetings:{attendance_type}-{top_n}'
+ cache_key = f"stats:get_country_data_for_meetings:{attendance_type}-{top_n}"
sorted_meetings, datasets = cache.get(cache_key, (None, None))
if (sorted_meetings, datasets) == (None, None):
# Get registration status counts, aggregated by country_code
@@ -198,50 +211,52 @@ def get_country_data_for_meetings(attendance_type: Optional[str] = None, top_n:
queryset = (
base_registrations
.values(
- 'meeting__number', # e.g. "118", "119", "120"
- 'country_code' # country code of the participant
+ "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
)
# Prepare country affiliation data, applying canonicalization and aliasing
# Mainly used to conver 2-letter country code into a full name
# Could possible use Country directly
- alias_map = get_aliased_countries(country_code for country_code in queryset.values_list('country_code', flat=True))
+ alias_map = get_aliased_countries(country_code
+ for country_code
+ in queryset.values_list("country_code", flat=True))
# ── Step 1: Collect all meetings and country totals ──
meetings_set: set[str] = set()
- country_totals: Dict[str, int] = defaultdict(int)
- data_map: Dict[str, Dict[str, int]] = defaultdict(dict) # {country: {meeting: count}}
-
+ country_totals: dict[str, int] = defaultdict(int)
+ data_map: dict[str, dict[str, int]] = defaultdict(dict) # {country: {meeting: count}}
+
for row in queryset:
- meeting = row['meeting__number']
- country = alias_map.get(row['country_code'], row['country_code'])
- count = row['participant_count']
-
+ meeting = row["meeting__number"]
+ country = alias_map.get(row["country_code"], 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
+ reverse=True,
)[:top_n]
-
+
# -- Step 3.bis do the 'other' category --
non_top_countries = set(country_totals.keys()) - set(top_countries)
- other_totals: Dict[str, int] = defaultdict(int)
+ other_totals: dict[str, int] = 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 = _build_timeline_datasets(top_countries, data_map, sorted_meetings, other_totals)
cache.set(
@@ -252,45 +267,52 @@ def get_country_data_for_meetings(attendance_type: Optional[str] = None, top_n:
return sorted_meetings, datasets
-def get_data_for_meetings(top_n: int = 20) -> Tuple[List[str], List[Dict[str, Any]]]:
+def get_data_for_meetings(top_n: int = 20) -> tuple[list[str], list[dict[str, Any]]]:
"""Get total participation data by attendance type for meetings timeline chart.
+ Args:
+ top_n: Number of top items to display in the chart.
+
Returns:
Tuple of (sorted_meetings, datasets) for Chart.js.
+
"""
- cache_key = f'stats:get_data_for_meetings:{top_n}'
+ cache_key = f"stats:get_data_for_meetings:{top_n}"
sorted_meetings, datasets = cache.get(cache_key, (None, None))
if (sorted_meetings, datasets) == (None, None):
# Get registration status counts, aggregated by ticket types
- base_registrations = Registration.objects.filter(tickets__attendance_type__in=['onsite', 'remote'])
+ base_registrations = (
+ Registration.objects
+ .filter(tickets__attendance_type__in=["onsite", "remote"])
+ )
queryset = (
base_registrations
.values(
- 'meeting__number', # e.g. "118", "119", "120"
- 'tickets__attendance_type'
+ "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[str] = set()
- tickets_totals: Dict[str, int] = defaultdict(int)
- data_map: Dict[str, Dict[str, int]] = defaultdict(dict) # {ticket: {meeting: count}}
-
+ tickets_totals: dict[str, int] = defaultdict(int)
+ data_map: dict[str, dict[str, int]] = defaultdict(dict) # {ticket: {meeting: count}}
+
for row in queryset:
- meeting = row['meeting__number']
- ticket = row['tickets__attendance_type']
- count = row['participant_count']
-
+ 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 ──
datasets = _build_timeline_datasets(list(ticket_types), data_map, sorted_meetings, {}, include_background_color=True)
cache.set(
@@ -300,74 +322,82 @@ def get_data_for_meetings(top_n: int = 20) -> Tuple[List[str], List[Dict[str, An
)
return sorted_meetings, datasets
-def meetings_timeline(request: Any, stats_type: str = 'country') -> Any:
+def meetings_timeline(request: Any, stats_type: str = "country") -> Any:
"""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.
+
"""
# Query parameters (from ?key=value)
try:
- top_n = max(1, min(int(request.GET.get('top', '20')), 100))
+ top_n = max(1, min(int(request.GET.get("top", "20")), 100))
except ValueError:
top_n = 20
# Check the top-n value against the allowed choices
if not check_top_n_choice(top_n):
- return render(request, "stats/error.html", {"message": f"Invalid top_n choice: {top_n}. Valid choices are: {get_top_n_choices()}"})
+ return render(request,
+ "stats/error.html",
+ {"message": f"Invalid top_n choice: {top_n}. Valid choices are: {get_top_n_choices()}"})
- if stats_type == 'total':
+ if stats_type == "total":
total_labels, total_data_sets = get_data_for_meetings(top_n=top_n)
- in_person_labels: List[str] = []
- in_person_data_sets: List[Dict[str, Any]] = []
- plural_stats_type = ''
- elif stats_type == 'affiliation':
+ in_person_labels: list[str] = []
+ in_person_data_sets: list[dict[str, Any]] = []
+ plural_stats_type = ""
+ elif stats_type == "affiliation":
total_labels, total_data_sets = get_affiliation_data_for_meetings(top_n=top_n)
- in_person_labels, in_person_data_sets = get_affiliation_data_for_meetings(attendance_type='onsite', top_n=top_n)
- plural_stats_type = 'affiliations'
- elif stats_type == 'country':
+ in_person_labels, in_person_data_sets = get_affiliation_data_for_meetings(attendance_type="onsite", top_n=top_n)
+ plural_stats_type = "affiliations"
+ elif stats_type == "country":
total_labels, total_data_sets = get_country_data_for_meetings(top_n=top_n)
- in_person_labels, in_person_data_sets = get_country_data_for_meetings(attendance_type='onsite', top_n=top_n)
- plural_stats_type = 'countries'
+ in_person_labels, in_person_data_sets = get_country_data_for_meetings(attendance_type="onsite", top_n=top_n)
+ plural_stats_type = "countries"
else:
return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
total_chart_data = {
- 'labels': total_labels,
- 'datasets': total_data_sets,
+ "labels": total_labels,
+ "datasets": total_data_sets,
}
# On per country/affiliation have a separate graph for inperson
- if stats_type == 'total':
+ if stats_type == "total":
in_person_chart_data = None
else:
in_person_chart_data = {
- 'labels': in_person_labels,
- 'datasets': in_person_data_sets,
+ "labels": in_person_labels,
+ "datasets": in_person_data_sets,
}
# Prepare the list of choice buttons for the template
possible_stats_types = [
- ("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'})),
+ ("affiliation", "Per affiliation", urlreverse(meetings_timeline,
+ kwargs={"stats_type": "affiliation"})),
+ ("country", "Per country", urlreverse(meetings_timeline,
+ kwargs={"stats_type": "country"})),
+ ("total", "Total", urlreverse(meetings_timeline,
+ kwargs={"stats_type": "total"})),
]
current_meeting = get_current_ietf_meeting_num()
- if stats_type == 'total':
- possible_stats_type = 'country'
+ if stats_type == "total":
+ possible_stats_type = "country"
else:
possible_stats_type = stats_type
- possible_meeting_numbers: List[Tuple[str | int, str]] = [
- ('All', urlreverse(meetings_timeline, kwargs={'stats_type': stats_type})),
- (int(current_meeting)-1, urlreverse(meeting_stats, kwargs={'meeting_number': int(current_meeting)-1, 'stats_type': possible_stats_type})),
- (int(current_meeting), urlreverse(meeting_stats, kwargs={'meeting_number': int(current_meeting), 'stats_type': possible_stats_type})),
- (int(current_meeting)+1, urlreverse(meeting_stats, kwargs={'meeting_number': int(current_meeting)+1, 'stats_type': possible_stats_type}))]
+ possible_meeting_numbers: list[tuple[str | int, str]] = [
+ ("All", urlreverse(meetings_timeline, kwargs={"stats_type": stats_type})),
+ (int(current_meeting)-1, urlreverse(meeting_stats,
+ kwargs={"meeting_number": int(current_meeting)-1, "stats_type": possible_stats_type})),
+ (int(current_meeting), urlreverse(meeting_stats,
+ kwargs={"meeting_number": int(current_meeting), "stats_type": possible_stats_type})),
+ (int(current_meeting)+1, urlreverse(meeting_stats,
+ kwargs={"meeting_number": int(current_meeting)+1, "stats_type": possible_stats_type}))]
return render(request, "stats/meetings_timeline.html", {
"top_n": top_n,
@@ -380,65 +410,79 @@ def meetings_timeline(request: Any, stats_type: str = 'country') -> Any:
"in_person_chart_data": in_person_chart_data,
})
-def get_affiliation_data_for_meeting(meeting_number: str, top_n: int = 20, attendance_type: Optional[str] = None) -> Tuple[List[str], List[int], int]:
+def get_affiliation_data_for_meeting(meeting_number: str, top_n: int = 20,
+ attendance_type: str | None = None) -> tuple[list[str], list[int], int]:
"""Get affiliation participation data for a specific meeting.
Args:
meeting_number: The meeting number.
+ top_n: Number of top items to display in the chart.
attendance_type: Optional filter for attendance type.
Returns:
- Tuple of (labels, data, total) for chart display.
+ Tuple of (labels, data, total) for chart.js display.
+
"""
# Get registration status details
base_registrations = Registration.objects.filter(meeting__number=meeting_number)
if attendance_type:
base_registrations = base_registrations.filter(tickets__attendance_type=attendance_type)
- registrations = base_registrations.values('affiliation')
+ registrations = base_registrations.values("affiliation")
- alias_map = get_aliased_affiliations(affiliation for affiliation in registrations.values_list('affiliation', flat=True))
+ alias_map = get_aliased_affiliations(affiliation for affiliation
+ in registrations.values_list("affiliation", flat=True))
# Count per canonicalized affiliation
- organization: Dict[str, int] = {}
+ organization: dict[str, int] = {}
for reg in registrations:
- if not reg['affiliation'] or not reg['affiliation'].strip():
- affiliation = 'Unspecified'
+ if not reg["affiliation"] or not reg["affiliation"].strip():
+ affiliation = "Unspecified"
else:
- affiliation = alias_map.get(reg['affiliation'], reg['affiliation'])
+ affiliation = alias_map.get(reg["affiliation"], reg["affiliation"])
organization[affiliation] = organization.get(affiliation, 0) + 1
# Sort to have the largest count first (nicer in pie chart)
sorted_orgs = sorted(organization.items(), key=lambda t: t[1], reverse=True)
return _build_pie_chart_data(sorted_orgs, top_n)
-def get_country_data_for_meeting(meeting_number: str, top_n: int = 20, attendance_type: Optional[str] = None) -> Tuple[List[str], List[int], int]:
+def get_country_data_for_meeting(meeting_number: str, top_n: int = 20,
+ attendance_type: str | None = None) -> tuple[list[str], list[int], int]:
"""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').
+ top_n: Number of top items to display in the chart.
attendance_type: Optional filter for attendance type.
Returns:
- Tuple of (labels, data, total) for chart display.
+ Tuple of (labels, data, total) for chart.js display.
+
"""
# Get registration status counts, aggregated by country_code
base_registration_counts = Registration.objects.filter(meeting__number=meeting_number)
if attendance_type:
- base_registration_counts = base_registration_counts.filter(tickets__attendance_type=attendance_type)
- registration_counts = base_registration_counts.values('country_code').annotate(count=Count('country_code')).order_by('-count')
+ base_registration_counts = (
+ base_registration_counts
+ .filter(tickets__attendance_type=attendance_type)
+ )
+ registration_counts = (
+ base_registration_counts
+ .values("country_code")
+ .annotate(count=Count("country_code"))
+ .order_by("-count")
+ )
+
+ alias_map = get_aliased_countries(reg for reg in registration_counts.values_list("country_code", flat=True))
- alias_map = get_aliased_countries(reg for reg in registration_counts.values_list('country_code', flat=True))
-
# Convert queryset to list of (label, count) tuples
items_with_counts = [
- (alias_map.get(item['country_code'], item['country_code']), item['count'])
+ (alias_map.get(item["country_code"], item["country_code"]), item["count"])
for item in registration_counts
]
-
+
return _build_pie_chart_data(items_with_counts, top_n)
-def meeting_stats(request: Any, meeting_number: Optional[str] = None, stats_type: str = 'country') -> Any:
+def meeting_stats(request: Any, meeting_number: str | None = None, stats_type: str = "country") -> Any:
"""Render statistics for a specific meeting.
Args:
@@ -448,66 +492,77 @@ def meeting_stats(request: Any, meeting_number: Optional[str] = None, stats_type
Returns:
Rendered response for the meeting stats template.
+
"""
current_meeting_number = get_current_ietf_meeting_num()
if meeting_number is None:
meeting_number = current_meeting_number
this_meeting = get_object_or_404(
- Meeting.objects.filter(type_id="ietf"), number=meeting_number
+ Meeting.objects.filter(type_id="ietf"), number=meeting_number,
)
# Query parameters (from ?key=value)
try:
- top_n = max(1, min(int(request.GET.get('top', '20')), 100))
+ top_n = max(1, min(int(request.GET.get("top", "20")), 100))
except ValueError:
top_n = 20
# Check the top-n value against the allowed choices
if not check_top_n_choice(top_n):
return render(request, "stats/error.html", {"message": f"Invalid top_n choice: {top_n}. Valid choices are: {get_top_n_choices()}"})
- if stats_type == 'affiliation':
+ if stats_type == "affiliation":
total_labels, total_data, total_total = get_affiliation_data_for_meeting(meeting_number, top_n=top_n)
- in_person_labels, in_person_data, in_person_total = get_affiliation_data_for_meeting(meeting_number, top_n=top_n, attendance_type='onsite')
- elif stats_type == 'country':
+ in_person_labels, in_person_data, in_person_total = get_affiliation_data_for_meeting(meeting_number, top_n=top_n, attendance_type="onsite")
+ elif stats_type == "country":
total_labels, total_data, total_total = get_country_data_for_meeting(meeting_number, top_n=top_n)
- in_person_labels, in_person_data, in_person_total = get_country_data_for_meeting(meeting_number, top_n=top_n, attendance_type='onsite')
+ in_person_labels, in_person_data, in_person_total = get_country_data_for_meeting(meeting_number, top_n=top_n, attendance_type="onsite")
else:
return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
total_chart_data = {
- 'labels': total_labels,
- 'datasets': [{
- 'label': f'Total Registrations by {stats_type}',
- 'data': total_data,
- 'backgroundColor': [color_from_hash(label) if label else '#202020' for label in total_labels],
- 'borderColor': '#ffffff',
- 'borderWidth': 2,
- }]
+ "labels": total_labels,
+ "datasets": [{
+ "label": f"Total Registrations by {stats_type}",
+ "data": total_data,
+ "backgroundColor": [color_from_hash(label)
+ if label else "#202020"
+ for label in total_labels],
+ "borderColor": "#ffffff",
+ "borderWidth": 2,
+ }],
}
in_person_chart_data = {
- 'labels': in_person_labels,
- 'datasets': [{
- 'label': f'In Person Registrations by {stats_type}',
- 'data': in_person_data,
- 'backgroundColor': [color_from_hash(label) if label else '#202020' for label in in_person_labels],
- 'borderColor': '#ffffff',
- 'borderWidth': 2,
- }]
+ "labels": in_person_labels,
+ "datasets": [{
+ "label": f"In Person Registrations by {stats_type}",
+ "data": in_person_data,
+ "backgroundColor": [color_from_hash(label)
+ if label else "#202020"
+ for label in in_person_labels],
+ "borderColor": "#ffffff",
+ "borderWidth": 2,
+ }],
}
# Prepare the list of choice buttons for the template
possible_stats_types = [
- ("affiliation", "Per affiliation", urlreverse(meeting_stats, kwargs={'meeting_number': meeting_number, 'stats_type': 'affiliation'})),
- ("country", "Per country", urlreverse(meeting_stats, kwargs={'meeting_number': meeting_number, 'stats_type': 'country'})),
+ ("affiliation", "Per affiliation", urlreverse(meeting_stats,
+ kwargs={"meeting_number": meeting_number, "stats_type": "affiliation"})),
+ ("country", "Per country", urlreverse(meeting_stats,
+ kwargs={"meeting_number": meeting_number, "stats_type": "country"})),
]
# Prepare the list of meeting number buttons for the template
- possible_meeting_numbers: List[Tuple[str | int, str]] = [('All', urlreverse(meetings_timeline, kwargs={'stats_type': stats_type}))]
+ possible_meeting_numbers: list[tuple[str | int, str]] = [("All", urlreverse(meetings_timeline,
+ kwargs={"stats_type": stats_type}))]
if int(meeting_number) > FIRST_MEETING_WITH_REGISTRATION_DATA:
- possible_meeting_numbers.append((int(meeting_number)-1, urlreverse(meeting_stats, kwargs={'meeting_number': int(meeting_number)-1, 'stats_type': stats_type})))
- possible_meeting_numbers.append((meeting_number, urlreverse(meeting_stats, kwargs={'meeting_number': meeting_number, 'stats_type': stats_type})))
+ possible_meeting_numbers.append((int(meeting_number)-1, urlreverse(meeting_stats,
+ kwargs={"meeting_number": int(meeting_number)-1, "stats_type": stats_type})))
+ possible_meeting_numbers.append((meeting_number, urlreverse(meeting_stats,
+ kwargs={"meeting_number": meeting_number, "stats_type": stats_type})))
if int(meeting_number) <= int(current_meeting_number): # Allow current meeting +1
- possible_meeting_numbers.append((int(meeting_number)+1, urlreverse(meeting_stats, kwargs={'meeting_number': int(meeting_number)+1, 'stats_type': stats_type})))
+ possible_meeting_numbers.append((int(meeting_number)+1, urlreverse(meeting_stats,
+ kwargs={"meeting_number": int(meeting_number)+1, "stats_type": stats_type})))
return render(request, "stats/meeting_stats.html", {
"meeting_number": meeting_number,
@@ -522,5 +577,5 @@ def meeting_stats(request: Any, meeting_number: Optional[str] = None, stats_type
"total_chart_data": total_chart_data,
"total_total": total_total,
"in_person_chart_data": in_person_chart_data,
- "in_person_total": in_person_total
+ "in_person_total": in_person_total,
})
From bb10e9f3b62b842cb4e2563e9c6f1a979abc9547 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Sat, 4 Jul 2026 05:55:47 +0000
Subject: [PATCH 127/181] Tame the UI to avoid per country stats about RFC to
bypass RfcAuthor current limitation
---
ietf/stats/views_authors.py | 48 ++++++++++++++++++++++++-------------
1 file changed, 31 insertions(+), 17 deletions(-)
diff --git a/ietf/stats/views_authors.py b/ietf/stats/views_authors.py
index 582212e0e01..52f52911d28 100644
--- a/ietf/stats/views_authors.py
+++ b/ietf/stats/views_authors.py
@@ -1,14 +1,13 @@
# Copyright The IETF Trust 2016-2026, All Rights Reserved
+from collections import defaultdict
+
from django.conf import settings
+from django.core.cache import cache
from django.db.models import Count, Q
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse as urlreverse
-from django.core.cache import cache
-from collections import defaultdict
-
-import debug # pyflakes:ignore
from ietf.doc.models import DocumentAuthor, RfcAuthor
from ietf.stats.utils import (
@@ -19,6 +18,7 @@
get_top_n_choices,
)
+
def get_authors_total_data_for_documents(doc_type: str = "all",
group_by: str = "country",
top_n: int = 20) -> dict[str, object]:
@@ -132,27 +132,33 @@ def authors_total(request: HttpRequest, doc_type: str = "all",
top_n = 10
# Check the top-n value against the allowed choices
if not check_top_n_choice(top_n):
- return render(request, "stats/error.html",
+ return render(request,
+ "stats/error.html",
{"message": f"Invalid top_n choice: {top_n}. Valid choices are: {get_top_n_choices()}"})
if stats_type == "affiliation":
chart_data = get_authors_total_data_for_documents(doc_type, "affiliation", top_n)
elif stats_type == "country":
+ if doc_type in ["all", "rfc"]:
+ return render(request,
+ "stats/error.html",
+ {"message": f"Country information not (yet) available for {doc_type} documents from the RFC Editor."})
chart_data = get_authors_total_data_for_documents(doc_type, "country", top_n)
else:
return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
# Prepare the list of choice buttons for the template
+ # A little tricky/ugly has in Juy 2026 there is no more country information for RFCs,
+ # so we don't want to show that option for RFCs or all documents
possible_docs_types = [
- ("all", "All documents", urlreverse(authors_total, kwargs={"doc_type": "all", "stats_type": stats_type})),
("draft", "Drafts", urlreverse(authors_total, kwargs={"doc_type": "draft", "stats_type": stats_type})),
("wg-draft", "WG Drafts", urlreverse(authors_total, kwargs={"doc_type": "wg-draft", "stats_type": stats_type})),
- ("rfc", "RFCs", urlreverse(authors_total, kwargs={"doc_type": "rfc", "stats_type": stats_type})),
- ]
- possible_stats_types = [
- ("affiliation", "Affiliation", urlreverse(authors_total, kwargs={"doc_type": doc_type, "stats_type": "affiliation"})),
- ("country", "Country", urlreverse(authors_total, kwargs={"doc_type": doc_type, "stats_type": "country"})),
]
+ if stats_type != "country":
+ possible_docs_types = [("all", "All documents", urlreverse(authors_total, kwargs={"doc_type": "all", "stats_type": stats_type}))] + possible_docs_types + [("rfc", "RFCs", urlreverse(authors_total, kwargs={"doc_type": "rfc", "stats_type": stats_type})) ]
+ possible_stats_types = [("affiliation", "Affiliation", urlreverse(authors_total, kwargs={"doc_type": doc_type, "stats_type": "affiliation"}))]
+ if doc_type not in ["all", "rfc"]:
+ possible_stats_types.append(("country", "Country", urlreverse(authors_total, kwargs={"doc_type": doc_type, "stats_type": "country"})))
return render(request, "stats/documents_total.html", {
"top_n": top_n,
@@ -328,11 +334,17 @@ def authors_timeline(request: HttpRequest, doc_type: str = "all", stats_type: st
top_n = 20
# Check the top-n value against the allowed choices
if not check_top_n_choice(top_n):
- return render(request, "stats/error.html", {"message": f"Invalid top_n choice: {top_n}. Valid choices are: {get_top_n_choices()}"})
+ return render(request,
+ "stats/error.html",
+ {"message": f"Invalid top_n choice: {top_n}. Valid choices are: {get_top_n_choices()}"})
if stats_type == "affiliation":
total_labels, total_data_sets = get_authors_timeline_data_for_documents(doc_type, "affiliation", top_n)
elif stats_type == "country":
+ if doc_type in ["all", "rfc"]:
+ return render(request,
+ "stats/error.html",
+ {"message": f"Country information not (yet) available for {doc_type} documents from the RFC Editor."})
total_labels, total_data_sets = get_authors_timeline_data_for_documents(doc_type, "country", top_n)
else:
return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index"))
@@ -344,15 +356,17 @@ def authors_timeline(request: HttpRequest, doc_type: str = "all", stats_type: st
# Prepare the list of choice buttons for the template
possible_docs_types = [
- ("all", "All documents", urlreverse(authors_timeline, kwargs={"doc_type": "all", "stats_type": stats_type})),
("draft", "Drafts", urlreverse(authors_timeline, kwargs={"doc_type": "draft", "stats_type": stats_type})),
("wg-draft", "WG Drafts", urlreverse(authors_timeline, kwargs={"doc_type": "wg-draft", "stats_type": stats_type})),
- ("rfc", "RFCs", urlreverse(authors_timeline, kwargs={"doc_type": "rfc", "stats_type": stats_type})),
]
+ if stats_type != "country":
+ possible_docs_types = [("all", "All documents", urlreverse(authors_timeline, kwargs={"doc_type": "all", "stats_type": stats_type})),
+ ] + possible_docs_types + [
+ ("rfc", "RFCs", urlreverse(authors_timeline, kwargs={"doc_type": "rfc", "stats_type": stats_type}))]
possible_stats_types = [
- ("affiliation", "Affiliation", urlreverse(authors_timeline, kwargs={"doc_type": doc_type, "stats_type": "affiliation"})),
- ("country", "Country", urlreverse(authors_timeline, kwargs={"doc_type": doc_type, "stats_type": "country"})),
- ]
+ ("affiliation", "Affiliation", urlreverse(authors_timeline, kwargs={"doc_type": doc_type, "stats_type": "affiliation"}))]
+ if doc_type not in ["all", "rfc"]:
+ possible_stats_types.append(("country", "Country", urlreverse(authors_timeline, kwargs={"doc_type": doc_type, "stats_type": "country"})))
return render(request, "stats/documents_timeline.html", {
"top_n": top_n,
From 1165661f5be63b52e098c99c20f11f894cb47a4b Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Sun, 5 Jul 2026 05:52:36 +0000
Subject: [PATCH 128/181] Nicer identation
---
ietf/stats/views_authors.py | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/ietf/stats/views_authors.py b/ietf/stats/views_authors.py
index 52f52911d28..92fd746ff4a 100644
--- a/ietf/stats/views_authors.py
+++ b/ietf/stats/views_authors.py
@@ -230,16 +230,16 @@ def get_authors_timeline_data_for_documents(doc_type: str = "all", group_by: str
year_group_list = []
if draft_queryset is not None:
year_group_list += [
- (row.document.pub_date().year, getattr(row, group_by))
- for row in draft_queryset
- if row.document.pub_date() is not None
- ]
+ (row.document.pub_date().year, getattr(row, group_by))
+ for row in draft_queryset
+ if row.document.pub_date() is not None
+ ]
if rfc_queryset is not None:
year_group_list += [
- (row.document.pub_date().year, getattr(row, group_by))
- for row in rfc_queryset
- if row.document.pub_date() is not None
- ]
+ (row.document.pub_date().year, getattr(row, group_by))
+ for row in rfc_queryset
+ if row.document.pub_date() is not None
+ ]
if group_by == "affiliation":
alias_map = get_aliased_affiliations(group for _, group in year_group_list)
From 0f7804c7c8029edee6165ccca9bd90b1c2fee2e5 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Sun, 5 Jul 2026 05:52:45 +0000
Subject: [PATCH 129/181] More robust tests
---
ietf/stats/tests.py | 54 +++++++++++++++++++++++++++------------------
1 file changed, 32 insertions(+), 22 deletions(-)
diff --git a/ietf/stats/tests.py b/ietf/stats/tests.py
index 421cf20c754..14d02208503 100644
--- a/ietf/stats/tests.py
+++ b/ietf/stats/tests.py
@@ -78,11 +78,13 @@ def test_document_stats(self):
rfcBcpIAB2 = WgRfcFactory(std_level_id="bcp", stream_id="iab")
DocEventFactory(type="published_rfc", doc=rfcBcpIAB2, time=time1960)
wgDraftPsGroup1 = WgDraftFactory(name="draft-ietf-" + group1.acronym + "-random-thing", intended_std_level_id="ps", group=group1)
- NewRevisionDocEventFactory(doc=wgDraftPsGroup1, time=time1960)
+ # Using NewRevisionDocEventFactory(doc=wgDraftPsGroup1, rev="01", time=time1960) does not work
+ # as pub_date() always use the latest, i.e., more recent "new_revision" event, which is not what we want for this test.
+ wgDraftPsGroup1.docevent_set.filter(type="new_revision").update(time=time1960)
+ # The next 2 draft Documents have an auto-created "new_revision" event dated now, which is what we want for this test.
wgDraftPsGroup2 = WgDraftFactory(name="draft-ietf-" + group2.acronym + "-random-thing", intended_std_level_id="inf", group=group2)
- NewRevisionDocEventFactory(doc=wgDraftPsGroup2, time=timeNow)
+ # This one has no stream specified and no WG, so it will be counted as "Unspecified" in the stream statistics.
draftExp = DocumentFactory(type_id="draft", intended_std_level_id="exp")
- NewRevisionDocEventFactory(doc=draftExp, time=timeNow)
# Let's create some authors, first get some test strings for affiliations and countries
affiliation = factory.Faker("company").evaluate(None, None, {"locale": None})
@@ -92,20 +94,15 @@ def test_document_stats(self):
if re.sub(r",?\s*\S+\s*$", "", affiliation) != "":
affiliation = re.sub(r",?\s*\S+\s*$", "", affiliation)
country = factory.Faker("country").evaluate(None, None, {"locale": None})
+ # Later tests assume country is not BE/USA
+ # Let also ensure that the country is a single word to avoid wrongly canonicalised names
+ # Such as Brunei Darussalam or Lao People's Democratic Republic, which are not canonicalised in the tests below.
+ while country in {"Belgium", "United States of America"} or " " in country:
+ country = factory.Faker("country").evaluate(None, None, {"locale": None})
# Factory sometimes generates country names that are not exactly canonical
# causing problems in the tests below.
if country == "Korea":
country = "South Korea"
- elif country == "Brunei Darussalam":
- country = "Brunei"
- elif country == "Cape Verde":
- country = "Cabo Verde"
- elif country == "Lao People's Democratic Republic":
- country = "Laos"
- elif country == "British Virgin Islands":
- country = "Virgin Islands"
- elif country == "Pitcairn Islands":
- country = "Pitcairn"
# Create the various aliases ancilliary content
AffiliationIgnoredEndingFactory(ending="llc\\.?")
@@ -169,31 +166,38 @@ def test_document_stats(self):
# Extract the JSON embedded in the response
pq = PyQuery(r.content)
chart_data = json.loads(pq.find("script#chart_data").text())
- self.assertTrue(chart_data["labels"] == [yearNow])
+ self.assertTrue(chart_data["labels"] == [year1960, yearNow],
+ msg=f"Labels ({chart_data['labels']}) for years do not match expected values=[{year1960}, {yearNow}]")
self.assertTrue(
any(
- ds["label"] == "IETF" and ds["data"] == [2]
+ ds["label"] == "IETF" and ds["data"] == [1, 1]
for ds in chart_data["datasets"]
),
)
self.assertTrue(
any(
- ds["label"] == "Unspecified" and ds["data"] == [1]
+ ds["label"] == "Unspecified" and ds["data"] == [0, 1]
for ds in chart_data["datasets"]
),
)
# Test#4 the authors specific statistics: for all docs about the countries
+ # With the current production data, there is an error message, so check for it
r = self.client.get(urlreverse(ietf.stats.views_authors.authors_timeline, kwargs={"doc_type": "all", "stats_type": "country"}))
self.assertEqual(r.status_code, 200)
- self.assertContains(r, "All Authors by Country")
+ self.assertContains(r, "Country information not (yet) available")
+ # Test#4.bis the authors specific statistics: for all docs about the countries
+ r = self.client.get(urlreverse(ietf.stats.views_authors.authors_timeline, kwargs={"doc_type": "draft", "stats_type": "country"}))
+ self.assertEqual(r.status_code, 200)
+ self.assertContains(r, "Draft Authors by Country")
# Extract the JSON embedded in the response
pq = PyQuery(r.content)
chart_data = json.loads(pq.find("script#chart_data").text())
- self.assertTrue(chart_data["labels"] == [year1960, yearNow])
+ self.assertTrue(chart_data["labels"] == [year1960, yearNow],
+ msg=f"Labels ({chart_data['labels']}) for years do not match expected values=[{year1960}, {yearNow}]")
self.assertTrue(
any(
- ds["label"] == "United States of America" and ds["data"] == [2, 1]
+ ds["label"] == "United States of America" and ds["data"] == [0, 1]
for ds in chart_data["datasets"]
),
)
@@ -203,6 +207,12 @@ def test_document_stats(self):
for ds in chart_data["datasets"]
),
)
+ self.assertTrue(
+ any(
+ ds["label"] == country and ds["data"] == [1, 1]
+ for ds in chart_data["datasets"]
+ ),
+ )
# Test#5 the authors specific statistics: for all all rfcs about the affiliation
r = self.client.get(urlreverse(ietf.stats.views_authors.authors_timeline, kwargs={"doc_type": "rfc", "stats_type": "affiliation"}))
@@ -238,21 +248,21 @@ def test_document_stats(self):
# Extract the JSON embedded in the response
pq = PyQuery(r.content)
chart_data = json.loads(pq.find("script#chart_data").text())
- self.assertTrue(chart_data["labels"] == [yearNow])
+ self.assertTrue(chart_data["labels"] == [year1960, yearNow])
# Test sometimes failing below with the factory country name being different from the country name in the chart data,
# even though they should be the same country.
# Using casefold to make the comparison more robust, as the country names in the chart data are title-cased
# while the factory can return them in different cases.
self.assertTrue(
any(
- ds["label"].casefold() == country.casefold() and ds["data"] == [2]
+ ds["label"].casefold() == country.casefold() and ds["data"] == [1, 1]
for ds in chart_data["datasets"]
),
msg=f"Country '{country}' not found in chart data labels: {chart_data['datasets']}",
)
self.assertTrue(
any(
- ds["label"] == "Belgium" and ds["data"] == [1]
+ ds["label"] == "Belgium" and ds["data"] == [0, 1]
for ds in chart_data["datasets"]
),
)
From 2e42be15280b60b5ca44842f76267f4c7ee4ed29 Mon Sep 17 00:00:00 2001
From: Eric Vyncke
Date: Sun, 5 Jul 2026 06:43:31 +0000
Subject: [PATCH 130/181] Select faster default statistics
---
ietf/templates/base/menu.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/ietf/templates/base/menu.html b/ietf/templates/base/menu.html
index a131e3b87bb..c27ea4a7be9 100644
--- a/ietf/templates/base/menu.html
+++ b/ietf/templates/base/menu.html
@@ -441,12 +441,12 @@
- {% if stats_type == 'total' %}
+ {% if stats_type == 'reg_type' %}
This page provides a timeline of meeting registrations.
{% else %}
This page provides a timeline of meeting registrations by {{ stats_type }} with a limit of {{ top_n }} {{ plural_stats_type }}.
@@ -57,7 +57,7 @@