From 87d946786e8e8d50a5b54856c80aa7dc72ee3ba2 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 29 Sep 2025 11:41:06 -0300 Subject: [PATCH 01/12] refactor: author-based eligibility cleanup --- ietf/nomcom/utils.py | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/ietf/nomcom/utils.py b/ietf/nomcom/utils.py index 299a38f22d..d0b4a84dbf 100644 --- a/ietf/nomcom/utils.py +++ b/ietf/nomcom/utils.py @@ -27,7 +27,7 @@ from django.shortcuts import get_object_or_404 from ietf.dbtemplate.models import DBTemplate -from ietf.doc.models import DocEvent, NewRevisionDocEvent +from ietf.doc.models import DocEvent, NewRevisionDocEvent, State from ietf.group.models import Group, Role from ietf.person.models import Email, Person from ietf.mailtrigger.utils import gather_address_lists @@ -35,7 +35,7 @@ from ietf.meeting.utils import participants_for_meeting from ietf.utils.pipe import pipe from ietf.utils.mail import send_mail_text, send_mail, get_payload_text -from ietf.utils.log import log +from ietf.utils.log import assertion, log from ietf.person.name import unidecode_name from ietf.utils.timezone import date_today, datetime_from_date, DEADLINE_TZINFO @@ -608,9 +608,37 @@ def get_threerule_eligibility_querysets(date, base_qs, three_of_five_callable): ) ).distinct() - rfc_pks = set(DocEvent.objects.filter(type='published_rfc', time__gte=five_years_ago, time__lte=date_as_dt).values_list('doc__pk', flat=True)) - iesgappr_pks = set(DocEvent.objects.filter(type='iesg_approved', time__gte=five_years_ago, time__lte=date_as_dt).values_list('doc__pk',flat=True)) - qualifying_pks = rfc_pks.union(iesgappr_pks.difference(rfc_pks)) + # The author path is defined by "path 3" in section 4 of RFC 8989. It qualifies + # a person who has been a front-page listed author or editor of at least two IETF- + # stream RFCs within the last five years. An I-D in the RFC Editor queue that was + # approved by the IESG is treated as an RFC, using the date of entry to the RFC + # Editor queue as the date for qualification. + # + # First, get the RFCs using publication date + rfc_pks = set( + DocEvent.objects.filter( + type='published_rfc', + doc__type_id="rfc", + time__gte=five_years_ago, + time__lte=date_as_dt, + ).values_list('doc__pk', flat=True) + ) + # Second, get the IESG-approved I-Ds in the RFC Editor queue, excluding any that + # became RFCs already + became_rfc_state = State.objects.filter(type_id="draft", slug="rfc").first() + assertion("became_rfc_state is not None") + iesgappr_pks = set( + DocEvent.objects.filter( + type='iesg_approved', + doc__type_id="draft", + time__gte=five_years_ago, + time__lte=date_as_dt, + doc__states__type_id="draft-rfceditor", + ).exclude( + doc__states=became_rfc_state + ).values_list('doc__pk', flat=True) + ) + qualifying_pks = rfc_pks.union(iesgappr_pks) author_qs = base_qs.filter( documentauthor__document__pk__in=qualifying_pks # BIG TODO: make sure rfcauthor gets counted here - overqualify if necessary ).annotate( From f53c0affb8cd801ce31e7325a35b6cd1bb7ee667 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 29 Sep 2025 15:07:27 -0300 Subject: [PATCH 02/12] feat: use rfcauthor recs for nomcom eligbility --- ietf/nomcom/utils.py | 61 +++++++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/ietf/nomcom/utils.py b/ietf/nomcom/utils.py index d0b4a84dbf..b8d15b6fe1 100644 --- a/ietf/nomcom/utils.py +++ b/ietf/nomcom/utils.py @@ -18,7 +18,7 @@ from email.utils import parseaddr from textwrap import dedent -from django.db.models import Q, Count +from django.db.models import Q, Count, F from django.conf import settings from django.contrib.sites.models import Site from django.core.exceptions import ObjectDoesNotExist @@ -27,7 +27,7 @@ from django.shortcuts import get_object_or_404 from ietf.dbtemplate.models import DBTemplate -from ietf.doc.models import DocEvent, NewRevisionDocEvent, State +from ietf.doc.models import DocEvent, NewRevisionDocEvent, State, Document from ietf.group.models import Group, Role from ietf.person.models import Email, Person from ietf.mailtrigger.utils import gather_address_lists @@ -615,35 +615,48 @@ def get_threerule_eligibility_querysets(date, base_qs, three_of_five_callable): # Editor queue as the date for qualification. # # First, get the RFCs using publication date - rfc_pks = set( - DocEvent.objects.filter( - type='published_rfc', - doc__type_id="rfc", - time__gte=five_years_ago, - time__lte=date_as_dt, - ).values_list('doc__pk', flat=True) + qualifying_rfc_pub_events = DocEvent.objects.filter( + type='published_rfc', + time__gte=five_years_ago, + time__lte=date_as_dt, ) + qualifying_rfcs = Document.objects.filter( + type_id="rfc", + docevent__in=qualifying_rfc_pub_events + ).annotate( + rfcauthor_count=Count("rfcauthor") + ) + rfcs_with_rfcauthors = qualifying_rfcs.filter(rfcauthor_count__gt=0).distinct() + rfcs_without_rfcauthors = qualifying_rfcs.filter(rfcauthor_count=0).distinct() + + # rfc_pks = set(qualifying_rfcs.values_list('doc__pk', flat=True)) # Second, get the IESG-approved I-Ds in the RFC Editor queue, excluding any that # became RFCs already became_rfc_state = State.objects.filter(type_id="draft", slug="rfc").first() assertion("became_rfc_state is not None") - iesgappr_pks = set( - DocEvent.objects.filter( - type='iesg_approved', - doc__type_id="draft", - time__gte=five_years_ago, - time__lte=date_as_dt, - doc__states__type_id="draft-rfceditor", - ).exclude( - doc__states=became_rfc_state - ).values_list('doc__pk', flat=True) + qualifying_approval_events = DocEvent.objects.filter( + type='iesg_approved', + time__gte=five_years_ago, + time__lte=date_as_dt, ) - qualifying_pks = rfc_pks.union(iesgappr_pks) + qualifying_drafts = Document.objects.filter( + type_id="draft", + states__type_id="draft-rfceditor", # ie, in the RFC Editor queue + docevent__in=qualifying_approval_events, + ).exclude( + states=became_rfc_state + ).distinct() + author_qs = base_qs.filter( - documentauthor__document__pk__in=qualifying_pks # BIG TODO: make sure rfcauthor gets counted here - overqualify if necessary - ).annotate( - document_author_count = Count('documentauthor') - ).filter(document_author_count__gte=2) + Q(documentauthor__document__in=qualifying_drafts) + | Q(rfcauthor__document__in=rfcs_with_rfcauthors) + | Q(documentauthor__document__in=rfcs_without_rfcauthors) + ).annotate( + document_author_count=Count('documentauthor'), + rfc_author_count=Count("rfcauthor") + ).annotate( + authorship_count=F("document_author_count") + F("rfc_author_count") + ).filter(authorship_count__gte=2) return three_of_five_qs, officer_qs, author_qs def list_eligible_8989(date, base_qs=None): From 549ebd45d6aceec841b1c971c53cc77665928f5f Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 29 Sep 2025 15:16:06 -0300 Subject: [PATCH 03/12] chore: remove commented code --- ietf/nomcom/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ietf/nomcom/utils.py b/ietf/nomcom/utils.py index b8d15b6fe1..edf80c6266 100644 --- a/ietf/nomcom/utils.py +++ b/ietf/nomcom/utils.py @@ -629,7 +629,6 @@ def get_threerule_eligibility_querysets(date, base_qs, three_of_five_callable): rfcs_with_rfcauthors = qualifying_rfcs.filter(rfcauthor_count__gt=0).distinct() rfcs_without_rfcauthors = qualifying_rfcs.filter(rfcauthor_count=0).distinct() - # rfc_pks = set(qualifying_rfcs.values_list('doc__pk', flat=True)) # Second, get the IESG-approved I-Ds in the RFC Editor queue, excluding any that # became RFCs already became_rfc_state = State.objects.filter(type_id="draft", slug="rfc").first() From 83408a7452fbe0c72e14a3470a7a101d29208880 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 30 Sep 2025 15:04:36 -0300 Subject: [PATCH 04/12] refactor: factor out helper for testing --- ietf/nomcom/utils.py | 100 ++++++++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 43 deletions(-) diff --git a/ietf/nomcom/utils.py b/ietf/nomcom/utils.py index d1e20e3be9..9afcc18e4e 100644 --- a/ietf/nomcom/utils.py +++ b/ietf/nomcom/utils.py @@ -18,7 +18,7 @@ from email.utils import parseaddr from textwrap import dedent -from django.db.models import Q, Count, F +from django.db.models import Q, Count, F, QuerySet from django.conf import settings from django.contrib.sites.models import Site from django.core.exceptions import ObjectDoesNotExist @@ -576,49 +576,28 @@ def get_8989_eligibility_querysets(date, base_qs): def get_9389_eligibility_querysets(date, base_qs): return get_threerule_eligibility_querysets(date, base_qs, three_of_five_callable=three_of_five_eligible_9389) -def get_threerule_eligibility_querysets(date, base_qs, three_of_five_callable): - if not base_qs: - base_qs = Person.objects.all() - - previous_five = previous_five_meetings(date) - date_as_dt = datetime_from_date(date, DEADLINE_TZINFO) - three_of_five_qs = three_of_five_callable(previous_five=previous_five, queryset=base_qs) - - # If date is Feb 29, neither 3 nor 5 years ago has a Feb 29. Use Feb 28 instead. - if date.month == 2 and date.day == 29: - three_years_ago = datetime.datetime(date.year - 3, 2, 28, tzinfo=DEADLINE_TZINFO) - five_years_ago = datetime.datetime(date.year - 5, 2, 28, tzinfo=DEADLINE_TZINFO) - else: - three_years_ago = datetime.datetime(date.year - 3, date.month, date.day, tzinfo=DEADLINE_TZINFO) - five_years_ago = datetime.datetime(date.year - 5, date.month, date.day, tzinfo=DEADLINE_TZINFO) - - officer_qs = base_qs.filter( - # is currently an officer - Q(role__name_id__in=('chair','secr'), - role__group__state_id='active', - role__group__type_id='wg', - role__group__time__lte=date_as_dt, ## TODO - inspect - lots of things affect group__time... - ) - # was an officer since the given date (I think this is wrong - it looks at when roles _start_, not when roles end) - | Q(rolehistory__group__time__gte=three_years_ago, - rolehistory__group__time__lte=date_as_dt, - rolehistory__name_id__in=('chair','secr'), - rolehistory__group__state_id='active', - rolehistory__group__type_id='wg', - ) - ).distinct() - # The author path is defined by "path 3" in section 4 of RFC 8989. It qualifies - # a person who has been a front-page listed author or editor of at least two IETF- - # stream RFCs within the last five years. An I-D in the RFC Editor queue that was - # approved by the IESG is treated as an RFC, using the date of entry to the RFC - # Editor queue as the date for qualification. - # +def get_qualified_author_queryset( + base_qs: QuerySet[Person], + eligibility_period_start: datetime.datetime, + eligibility_period_end: datetime.datetime, +): + """Filter a Person queryset, keeping those qualified by RFC 8989's author path + + The author path is defined by "path 3" in section 4 of RFC 8989. It qualifies + a person who has been a front-page listed author or editor of at least two IETF- + stream RFCs within the last five years. An I-D in the RFC Editor queue that was + approved by the IESG is treated as an RFC, using the date of entry to the RFC + Editor queue as the date for qualification. + + Arguments eligibility_period_start and eligibility_period_end are datetimes that + mark the start and end of the eligibility period. These should be five years apart. + """ # First, get the RFCs using publication date qualifying_rfc_pub_events = DocEvent.objects.filter( type='published_rfc', - time__gte=five_years_ago, - time__lte=date_as_dt, + time__gte=eligibility_period_start, + time__lte=eligibility_period_end, ) qualifying_rfcs = Document.objects.filter( type_id="rfc", @@ -635,8 +614,8 @@ def get_threerule_eligibility_querysets(date, base_qs, three_of_five_callable): assertion("became_rfc_state is not None") qualifying_approval_events = DocEvent.objects.filter( type='iesg_approved', - time__gte=five_years_ago, - time__lte=date_as_dt, + time__gte=eligibility_period_start, + time__lte=eligibility_period_end, ) qualifying_drafts = Document.objects.filter( type_id="draft", @@ -646,7 +625,7 @@ def get_threerule_eligibility_querysets(date, base_qs, three_of_five_callable): states=became_rfc_state ).distinct() - author_qs = base_qs.filter( + return base_qs.filter( Q(documentauthor__document__in=qualifying_drafts) | Q(rfcauthor__document__in=rfcs_with_rfcauthors) | Q(documentauthor__document__in=rfcs_without_rfcauthors) @@ -656,6 +635,41 @@ def get_threerule_eligibility_querysets(date, base_qs, three_of_five_callable): ).annotate( authorship_count=F("document_author_count") + F("rfc_author_count") ).filter(authorship_count__gte=2) + + +def get_threerule_eligibility_querysets(date, base_qs, three_of_five_callable): + if not base_qs: + base_qs = Person.objects.all() + + previous_five = previous_five_meetings(date) + date_as_dt = datetime_from_date(date, DEADLINE_TZINFO) + three_of_five_qs = three_of_five_callable(previous_five=previous_five, queryset=base_qs) + + # If date is Feb 29, neither 3 nor 5 years ago has a Feb 29. Use Feb 28 instead. + if date.month == 2 and date.day == 29: + three_years_ago = datetime.datetime(date.year - 3, 2, 28, tzinfo=DEADLINE_TZINFO) + five_years_ago = datetime.datetime(date.year - 5, 2, 28, tzinfo=DEADLINE_TZINFO) + else: + three_years_ago = datetime.datetime(date.year - 3, date.month, date.day, tzinfo=DEADLINE_TZINFO) + five_years_ago = datetime.datetime(date.year - 5, date.month, date.day, tzinfo=DEADLINE_TZINFO) + + officer_qs = base_qs.filter( + # is currently an officer + Q(role__name_id__in=('chair','secr'), + role__group__state_id='active', + role__group__type_id='wg', + role__group__time__lte=date_as_dt, ## TODO - inspect - lots of things affect group__time... + ) + # was an officer since the given date (I think this is wrong - it looks at when roles _start_, not when roles end) + | Q(rolehistory__group__time__gte=three_years_ago, + rolehistory__group__time__lte=date_as_dt, + rolehistory__name_id__in=('chair','secr'), + rolehistory__group__state_id='active', + rolehistory__group__type_id='wg', + ) + ).distinct() + + author_qs = get_qualified_author_queryset(base_qs, five_years_ago, date_as_dt) return three_of_five_qs, officer_qs, author_qs def list_eligible_8989(date, base_qs=None): From 32c671b6e4dde24761b9c1d0a6191f8fd9f83a37 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 30 Sep 2025 16:34:44 -0300 Subject: [PATCH 05/12] test: test_get_qualified_author_queryset --- ietf/nomcom/tests.py | 137 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 131 insertions(+), 6 deletions(-) diff --git a/ietf/nomcom/tests.py b/ietf/nomcom/tests.py index f3561c2e27..f42a4f9a6e 100644 --- a/ietf/nomcom/tests.py +++ b/ietf/nomcom/tests.py @@ -27,8 +27,14 @@ from ietf.api.views import EmailIngestionError from ietf.dbtemplate.factories import DBTemplateFactory from ietf.dbtemplate.models import DBTemplate -from ietf.doc.factories import DocEventFactory, WgDocumentAuthorFactory, \ - NewRevisionDocEventFactory, DocumentAuthorFactory, RfcAuthorFactory +from ietf.doc.factories import ( + DocEventFactory, + WgDocumentAuthorFactory, + NewRevisionDocEventFactory, + DocumentAuthorFactory, + RfcAuthorFactory, + WgDraftFactory, WgRfcFactory, +) from ietf.group.factories import GroupFactory, GroupHistoryFactory, RoleFactory, RoleHistoryFactory from ietf.group.models import Group, Role from ietf.meeting.factories import MeetingFactory, AttendedFactory, RegistrationFactory @@ -45,10 +51,20 @@ nomcom_kwargs_for_year, provide_private_key_to_test_client, \ key from ietf.nomcom.tasks import send_nomcom_reminders_task -from ietf.nomcom.utils import get_nomcom_by_year, make_nomineeposition, \ - get_hash_nominee_position, is_eligible, list_eligible, \ - get_eligibility_date, suggest_affiliation, ingest_feedback_email, \ - decorate_volunteers_with_qualifications, send_reminders, _is_time_to_send_reminder +from ietf.nomcom.utils import ( + get_nomcom_by_year, + make_nomineeposition, + get_hash_nominee_position, + is_eligible, + list_eligible, + get_eligibility_date, + suggest_affiliation, + ingest_feedback_email, + decorate_volunteers_with_qualifications, + send_reminders, + _is_time_to_send_reminder, + get_qualified_author_queryset, +) from ietf.person.factories import PersonFactory, EmailFactory from ietf.person.models import Email, Person from ietf.utils.mail import outbox, empty_outbox, get_payload_text @@ -2440,6 +2456,115 @@ def test_get_eligibility_date(self): NomComFactory(group__acronym=f'nomcom{this_year}', first_call_for_volunteers=datetime.date(this_year,5,6)) self.assertEqual(get_eligibility_date(),datetime.date(this_year,5,6)) + def test_get_qualified_author_queryset(self): + """get_qualified_author_queryset implements the eligiblity rules correctly + + Note on methodology: rather than moving events around a fixed 5-year + eligibility period, this takes advantage of the method-under-test's accepting + start and end datetimes for the eligibility interval. Events are fixed in time + and the start and end are adjusted to include various combinations. + + This is not an exhaustive test of corner cases. + """ + people = PersonFactory.create_batch(2) + base_qs = Person.objects.filter(pk__in=[person.pk for person in people]) + now = datetime.datetime.now(tz=datetime.UTC) + one_year = datetime.timedelta(days=365) + + approved_draft = WgDraftFactory( + authors=people, + states=[("draft", "active"), ("draft-rfceditor", "auth48")] + ) + DocEventFactory( + type="iesg_approved", + doc=approved_draft, + time=now - 4 * one_year, + ) + + approved_draft_not_in_queue = WgDraftFactory( + authors=people, + states=[("draft", "active")] + ) + DocEventFactory( + type="iesg_approved", + doc=approved_draft_not_in_queue, + time=now - 4 * one_year, + ) + + # The draft-rfceditor state on this draft is not representative of real data + # but is helpful for testing succinctly. It ensures that the logic is using + # the ("draft", "rfc") state to exclude this draft from eligibility calcs. + published_draft = WgDraftFactory( + authors=people, states=[ + ("draft", "rfc"), ("draft-rfceditor", "auth48")] + ) + DocEventFactory( + type="iesg_approved", + doc=published_draft, + time=now - 3 * one_year, + ) + rfc = WgRfcFactory( + authors=people, + group=published_draft.group, + ) + DocEventFactory( + type="published_rfc", + doc=rfc, + time=now - 2 * one_year, + ) + + # Compute over a period with no qualifying events in it + self.assertCountEqual( + get_qualified_author_queryset( + base_qs, now - 6 * one_year, now - 5 * one_year + ), + [], + ) + + # Period with two IESG-approved drafts, but one of these is not in the + # RFC editor queue for some reason (has no draft-rfceditor state) + self.assertCountEqual( + get_qualified_author_queryset( + base_qs, now - 4.5 * one_year, now - 3.5 * one_year + ), + [], + ) + + # Period including the IESG-approved drafts and the iesg_approved date for + # a draft published as an RFC outside the eligibility period + self.assertCountEqual( + get_qualified_author_queryset( + base_qs, now - 4.5 * one_year, now - 2.5 * one_year + ), + [], + ) + + # Now extend the eligibility to include the RFC's publication. This gives + # two eligible documents: the iesg-approved draft in the rfc editor queue and + # the published RFC. + self.assertCountEqual( + get_qualified_author_queryset( + base_qs, now - 4.5 * one_year, now - 1.5 * one_year + ), + people, + ) + + # Now add an RfcAuthor for only one of the two authors to the RFC. This should + # remove the other author from the eligibility list because the DocumentAuthor + # records are no longer used. + RfcAuthorFactory( + document=rfc, + person=people[0], + titlepage_name="P. Zero", + email=people[0].email_set.first(), + ) + self.assertCountEqual( + get_qualified_author_queryset( + base_qs, now - 4.5 * one_year, now - 1.5 * one_year + ), + [people[0]], + ) + class rfc8713EligibilityTests(TestCase): From 84c406bd62d204cb53e779d413a0d0307b2a9d02 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 30 Sep 2025 16:45:55 -0300 Subject: [PATCH 06/12] fix: restore a necessary import --- ietf/doc/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ietf/doc/models.py b/ietf/doc/models.py index 42a98cede1..4ba4ce6e2f 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -7,6 +7,7 @@ import logging import os +import django.db import rfc2html from io import BufferedReader From b990b6552c4b72747df566d21d565f7f029b5ad4 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Wed, 1 Oct 2025 11:16:40 -0300 Subject: [PATCH 07/12] test: fix test_elig_by_author --- ietf/nomcom/tests.py | 45 +++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/ietf/nomcom/tests.py b/ietf/nomcom/tests.py index f42a4f9a6e..5807297649 100644 --- a/ietf/nomcom/tests.py +++ b/ietf/nomcom/tests.py @@ -1,5 +1,4 @@ -# Copyright The IETF Trust 2012-2023, All Rights Reserved -# -*- coding: utf-8 -*- +# Copyright The IETF Trust 2012-2025, All Rights Reserved import datetime @@ -2849,33 +2848,41 @@ def test_elig_by_author(self): ineligible = set() p = PersonFactory() - ineligible.add(p) - + ineligible.add(p) # no RFCs or iesg-approved drafts p = PersonFactory() - da = WgDocumentAuthorFactory(person=p) - DocEventFactory(type='published_rfc',doc=da.document,time=middle_date) - ineligible.add(p) + doc = WgRfcFactory(authors=[p]) + DocEventFactory(type='published_rfc', doc=doc, time=middle_date) + ineligible.add(p) # only one RFC p = PersonFactory() - da = WgDocumentAuthorFactory(person=p) + da = WgDocumentAuthorFactory( + person=p, + document__states=[("draft", "active"), ("draft-rfceditor", "ref")], + ) DocEventFactory(type='iesg_approved',doc=da.document,time=last_date) - da = WgDocumentAuthorFactory(person=p) - DocEventFactory(type='published_rfc',doc=da.document,time=first_date) - eligible.add(p) + doc = WgRfcFactory(authors=[p]) + DocEventFactory(type='published_rfc', doc=doc, time=first_date) + eligible.add(p) # one RFC and one iesg-approved draft p = PersonFactory() - da = WgDocumentAuthorFactory(person=p) + da = WgDocumentAuthorFactory( + person=p, + document__states=[("draft", "active"), ("draft-rfceditor", "ref")], + ) DocEventFactory(type='iesg_approved',doc=da.document,time=middle_date) - da = WgDocumentAuthorFactory(person=p) - DocEventFactory(type='published_rfc',doc=da.document,time=day_before_first_date) - ineligible.add(p) + doc = WgRfcFactory(authors=[p]) + DocEventFactory(type='published_rfc', doc=doc, time=day_before_first_date) + ineligible.add(p) # RFC is out of the eligibility window p = PersonFactory() - da = WgDocumentAuthorFactory(person=p) + da = WgDocumentAuthorFactory( + person=p, + document__states=[("draft", "active"), ("draft-rfceditor", "ref")], + ) DocEventFactory(type='iesg_approved',doc=da.document,time=day_after_last_date) - da = WgDocumentAuthorFactory(person=p) - DocEventFactory(type='published_rfc',doc=da.document,time=middle_date) - ineligible.add(p) + doc = WgRfcFactory(authors=[p]) + DocEventFactory(type='published_rfc', doc=doc, time=middle_date) + ineligible.add(p) # iesg approval is outside the eligibility window for person in eligible: self.assertTrue(is_eligible(person,nomcom)) From 7ccc7bce11326f2db18c96f4b9e4330f491dbb4b Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Wed, 1 Oct 2025 11:19:49 -0300 Subject: [PATCH 08/12] test: fix test_decorate_volunteers_with_qualifications --- ietf/nomcom/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ietf/nomcom/tests.py b/ietf/nomcom/tests.py index 5807297649..c29f855b18 100644 --- a/ietf/nomcom/tests.py +++ b/ietf/nomcom/tests.py @@ -3077,10 +3077,10 @@ def test_decorate_volunteers_with_qualifications(self): author_person = PersonFactory() for i in range(2): - da = WgDocumentAuthorFactory(person=author_person) + doc = WgRfcFactory(authors=[author_person]) DocEventFactory( type='published_rfc', - doc=da.document, + doc=doc, time=datetime.datetime( elig_date.year - 3, elig_date.month, From 87d3d7f0938664716ae5b54838d641d7a89cdc94 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Wed, 1 Oct 2025 11:26:23 -0300 Subject: [PATCH 09/12] test: add comment --- ietf/nomcom/tests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ietf/nomcom/tests.py b/ietf/nomcom/tests.py index c29f855b18..1322385156 100644 --- a/ietf/nomcom/tests.py +++ b/ietf/nomcom/tests.py @@ -2463,7 +2463,8 @@ def test_get_qualified_author_queryset(self): start and end datetimes for the eligibility interval. Events are fixed in time and the start and end are adjusted to include various combinations. - This is not an exhaustive test of corner cases. + This is not an exhaustive test of corner cases. Overlaps considerably with + rfc8989EligibilityTests.test_elig_by_author(). """ people = PersonFactory.create_batch(2) base_qs = Person.objects.filter(pk__in=[person.pk for person in people]) From dfad1913c8cc2dbd1bcd0dcc06c29477f891ae22 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 27 Oct 2025 20:44:49 -0300 Subject: [PATCH 10/12] fix: drop test for draft-rfceditor state Attempted to limit to drafts literally in the queue, but was not a valid check when looking back in time. As a practical matter, the test is not necessary. --- ietf/nomcom/utils.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ietf/nomcom/utils.py b/ietf/nomcom/utils.py index 9afcc18e4e..cff3276d3b 100644 --- a/ietf/nomcom/utils.py +++ b/ietf/nomcom/utils.py @@ -590,6 +590,12 @@ def get_qualified_author_queryset( approved by the IESG is treated as an RFC, using the date of entry to the RFC Editor queue as the date for qualification. + This method does not strictly enforce "in the RFC Editor queue" for IESG-approved + drafts when computing eligibility. In the overwhelming majority of cases, an IESG- + approved draft immediately enters the queue and goes on to be published, so this + simplification makes the calculation much easier and virtually never affects + eligibility. + Arguments eligibility_period_start and eligibility_period_end are datetimes that mark the start and end of the eligibility period. These should be five years apart. """ @@ -608,8 +614,7 @@ def get_qualified_author_queryset( rfcs_with_rfcauthors = qualifying_rfcs.filter(rfcauthor_count__gt=0).distinct() rfcs_without_rfcauthors = qualifying_rfcs.filter(rfcauthor_count=0).distinct() - # Second, get the IESG-approved I-Ds in the RFC Editor queue, excluding any that - # became RFCs already + # Second, get the IESG-approved I-Ds excluding any that became RFCs already became_rfc_state = State.objects.filter(type_id="draft", slug="rfc").first() assertion("became_rfc_state is not None") qualifying_approval_events = DocEvent.objects.filter( @@ -619,7 +624,6 @@ def get_qualified_author_queryset( ) qualifying_drafts = Document.objects.filter( type_id="draft", - states__type_id="draft-rfceditor", # ie, in the RFC Editor queue docevent__in=qualifying_approval_events, ).exclude( states=became_rfc_state From b54b3239d6a4bcd80790e1c90db3200635a9c7a4 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 28 Oct 2025 13:34:30 -0300 Subject: [PATCH 11/12] fix: exclude double counting, not rfc pub state --- ietf/nomcom/utils.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ietf/nomcom/utils.py b/ietf/nomcom/utils.py index cff3276d3b..8017bb129c 100644 --- a/ietf/nomcom/utils.py +++ b/ietf/nomcom/utils.py @@ -614,9 +614,7 @@ def get_qualified_author_queryset( rfcs_with_rfcauthors = qualifying_rfcs.filter(rfcauthor_count__gt=0).distinct() rfcs_without_rfcauthors = qualifying_rfcs.filter(rfcauthor_count=0).distinct() - # Second, get the IESG-approved I-Ds excluding any that became RFCs already - became_rfc_state = State.objects.filter(type_id="draft", slug="rfc").first() - assertion("became_rfc_state is not None") + # Second, get the IESG-approved I-Ds excluding any we're already counting as rfcs qualifying_approval_events = DocEvent.objects.filter( type='iesg_approved', time__gte=eligibility_period_start, @@ -626,7 +624,8 @@ def get_qualified_author_queryset( type_id="draft", docevent__in=qualifying_approval_events, ).exclude( - states=became_rfc_state + relateddocument__relationship_id="became_rfc", + relateddocument__target__in=qualifying_rfcs, ).distinct() return base_qs.filter( From d2c091d5fc9767119a65a225b35676fcfdb3b38e Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 28 Oct 2025 13:34:38 -0300 Subject: [PATCH 12/12] test: update test --- ietf/nomcom/tests.py | 90 +++++++++++++++----------------------------- 1 file changed, 30 insertions(+), 60 deletions(-) diff --git a/ietf/nomcom/tests.py b/ietf/nomcom/tests.py index 1322385156..b6e8c57da7 100644 --- a/ietf/nomcom/tests.py +++ b/ietf/nomcom/tests.py @@ -2457,96 +2457,68 @@ def test_get_eligibility_date(self): def test_get_qualified_author_queryset(self): """get_qualified_author_queryset implements the eligiblity rules correctly - - Note on methodology: rather than moving events around a fixed 5-year - eligibility period, this takes advantage of the method-under-test's accepting - start and end datetimes for the eligibility interval. Events are fixed in time - and the start and end are adjusted to include various combinations. - + This is not an exhaustive test of corner cases. Overlaps considerably with rfc8989EligibilityTests.test_elig_by_author(). """ people = PersonFactory.create_batch(2) + extra_person = PersonFactory() base_qs = Person.objects.filter(pk__in=[person.pk for person in people]) now = datetime.datetime.now(tz=datetime.UTC) one_year = datetime.timedelta(days=365) - approved_draft = WgDraftFactory( - authors=people, - states=[("draft", "active"), ("draft-rfceditor", "auth48")] + # Authors with no qualifying drafts + self.assertCountEqual( + get_qualified_author_queryset(base_qs, now - 5 * one_year, now), [] ) + + # Authors with one qualifying draft + approved_draft = WgDraftFactory(authors=people, states=[("draft", "active")]) DocEventFactory( type="iesg_approved", doc=approved_draft, time=now - 4 * one_year, ) - - approved_draft_not_in_queue = WgDraftFactory( - authors=people, - states=[("draft", "active")] - ) - DocEventFactory( - type="iesg_approved", - doc=approved_draft_not_in_queue, - time=now - 4 * one_year, - ) - - # The draft-rfceditor state on this draft is not representative of real data - # but is helpful for testing succinctly. It ensures that the logic is using - # the ("draft", "rfc") state to exclude this draft from eligibility calcs. - published_draft = WgDraftFactory( - authors=people, states=[ - ("draft", "rfc"), ("draft-rfceditor", "auth48")] + self.assertCountEqual( + get_qualified_author_queryset(base_qs, now - 5 * one_year, now), [] ) + + # Create a draft that was published into an RFC. Give it an extra author who + # should not be eligible. + published_draft = WgDraftFactory(authors=people, states=[("draft", "rfc")]) DocEventFactory( type="iesg_approved", doc=published_draft, - time=now - 3 * one_year, + time=now - 5.5 * one_year, # < 6 years ago ) rfc = WgRfcFactory( - authors=people, + authors=people + [extra_person], group=published_draft.group, ) DocEventFactory( type="published_rfc", doc=rfc, - time=now - 2 * one_year, + time=now - 0.5 * one_year, # < 1 year ago ) - - # Compute over a period with no qualifying events in it + # Period 6 years ago to 1 year ago - authors are eligible due to the + # iesg-approved draft in this window self.assertCountEqual( - get_qualified_author_queryset( - base_qs, now - 6 * one_year, now - 5 * one_year - ), - [], + get_qualified_author_queryset(base_qs, now - 6 * one_year, now - one_year), + people, ) - # Period with two IESG-approved drafts, but one of these is not in the - # RFC editor queue for some reason (has no draft-rfceditor state) + # Period 5 years ago to now - authors are eligible due to the RFC publication self.assertCountEqual( - get_qualified_author_queryset( - base_qs, now - 4.5 * one_year, now - 3.5 * one_year - ), - [], - ) - - # Period including the IESG-approved drafts and the iesg_approved date for - # a draft published as an RFC outside the eligibility period - self.assertCountEqual( - get_qualified_author_queryset( - base_qs, now - 4.5 * one_year, now - 2.5 * one_year - ), - [], + get_qualified_author_queryset(base_qs, now - 5 * one_year, now), + people, ) - # Now extend the eligibility to include the RFC's publication. This gives - # two eligible documents: the iesg-approved draft in the rfc editor queue and - # the published RFC. + # Use the extra_person to check that a single doc can't count both as an + # RFC _and_ an approved draft. Use an eligibility interval that includes both + # the approval and the RFC publication self.assertCountEqual( - get_qualified_author_queryset( - base_qs, now - 4.5 * one_year, now - 1.5 * one_year - ), - people, + get_qualified_author_queryset(base_qs, now - 6 * one_year, now), + people, # does not include extra_person! ) # Now add an RfcAuthor for only one of the two authors to the RFC. This should @@ -2559,9 +2531,7 @@ def test_get_qualified_author_queryset(self): email=people[0].email_set.first(), ) self.assertCountEqual( - get_qualified_author_queryset( - base_qs, now - 4.5 * one_year, now - 1.5 * one_year - ), + get_qualified_author_queryset(base_qs, now - 5 * one_year, now), [people[0]], )