From 89936899e3be26d6354f34ec96abda46a284d8e9 Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Mon, 17 Aug 2026 13:43:20 -0500 Subject: [PATCH 1/2] feat: show rfc editor queue status on search result rows --- ietf/doc/models.py | 35 +++- ietf/doc/tests.py | 169 +++++++++++++++++- ietf/doc/utils_search.py | 32 +++- ietf/doc/views_doc.py | 24 +-- ietf/doc/views_review.py | 3 +- ietf/templates/doc/search/status_columns.html | 17 +- 6 files changed, 242 insertions(+), 38 deletions(-) diff --git a/ietf/doc/models.py b/ietf/doc/models.py index 708bbb2ede9..3c9b80ca83a 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -401,8 +401,33 @@ def get_state_slug(self, state_type=None): self._cached_state_slug[state_type] = s.slug if s else None return self._cached_state_slug[state_type] - def friendly_state(self): - """ Return a concise text description of the document's current state.""" + def rfc_editor_queue_status(self): + """Human-readable RPC publication queue "Status" for the document, or None. + + While a document is in the RFC Editor queue (draft-rfceditor state + "in_progress" or "blocked"), this is the status text pushed by the RFC + Production Center, matching what the queue website shows. Displays of the RFC + Editor state show it in place of the state name. It is None for a document whose + draft-rfceditor state predates the queue integration, which leaves those displays + showing the state name itself. + + Costs a query per call. Tables rendering many documents replace this with a + precomputed value; see ietf.doc.utils_search.fill_in_rfc_editor_queue_status. + """ + if self.get_state_slug("draft-rfceditor") not in ("in_progress", "blocked"): + return None + event = self.latest_event(RpcAssignmentDocEvent, type="changed_rpc_assignments") + return event.assignments if event else None + + def friendly_state(self, label_iesg_state=True): + """ Return a concise text description of the document's current state. + + For a draft in the RFC Editor queue that description is "IESG: RFC Ed Queue", + labeled because displays of it sit next to the RFC Editor's own status for the + document. Every other state stands on its own and is returned unlabeled. Callers + rendering the description somewhere already labeled as the IESG's pass + label_iesg_state=False. + """ state = self.get_state() if not state: return "Unknown state" @@ -440,7 +465,11 @@ def friendly_state(self): e = self.latest_event(LastCallDocEvent, type="sent_last_call") if e: return iesg_state_summary + " (ends %s)" % e.expires.astimezone(DEADLINE_TZINFO).date().isoformat() - + elif label_iesg_state and iesg_state.slug == "rfcqueue": + # The only state whose displays sit next to the RFC Editor's own + # status for the document, where a bare state name is ambiguous. + return "IESG: %s" % iesg_state_summary + return iesg_state_summary else: return "I-D Exists" diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index 95bee78ae5f..2e9494d0d87 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -86,7 +86,8 @@ from ietf.utils.test_utils import TestCase from ietf.utils.text import normalize_text, texescape from ietf.utils.timezone import date_today, datetime_today, DEADLINE_TZINFO, RPC_TZINFO -from ietf.doc.utils_search import AD_WORKLOAD, fill_in_telechat_date, prepare_document_table +from ietf.doc.utils_search import (AD_WORKLOAD, fill_in_rfc_editor_queue_status, + fill_in_telechat_date, prepare_document_table) class SearchTests(TestCase): @@ -308,21 +309,38 @@ def test_search_query_count_does_not_grow_with_results(self): so doubling the number of rows must not change the number of queries. A lookup that slipped back into the per-row path shows up here as a count that grows. - Mind the blind spots: these documents have no IESG state, ballot, last call, - action holders, telechat or obsoleting RFCs, so the per-row work the columns - driven by those still do is not covered. Widen the fixtures rather than reading - a pass here as "the table does no per-row queries". + Mind the blind spots: these documents have no ballot, last call, action holders, + telechat, obsoleting RFCs, or IESG state under way, so the per-row work the + columns driven by those still do is not covered -- state_age_colored and the + action holder list each still cost a query for a document being processed by the + IESG. Widen the fixtures rather than reading a pass here as "the table does no + per-row queries". """ group = GroupFactory(type_id="wg") url = urlreverse('ietf.doc.views_search.search') + ( f"?activedrafts=on&olddrafts=on&rfcs=on&by=group&group={group.acronym}" ) + system = Person.objects.get(name="(System)") def add_documents(count): for _ in range(count): WgDraftFactory(group=group, authors=[PersonFactory()], ad=PersonFactory(), shepherd=EmailFactory()) WgRfcFactory(group=group) + # A draft in the RFC Editor queue, whose row also shows the queue status. + # Not one the IESG is processing, to keep the blind spots above out of + # the count. + queued = WgDraftFactory( + group=group, + states=[("draft", "active"), ("draft-iesg", "idexists"), + ("draft-rfceditor", "in_progress")], + ) + RpcAssignmentDocEvent.objects.create( + doc=queued, rev=queued.rev, by=system, + type="changed_rpc_assignments", + assignments="In Progress (First Edit)", + desc="RPC status changed to In Progress (First Edit)", + ) def count_queries(): with CaptureQueriesContext(connection) as context: @@ -335,7 +353,7 @@ def count_queries(): add_documents(4) doubled = count_queries() - # A per-row lookup would add at least one query for each of the 8 new documents. + # A per-row lookup would add at least one query for each of the 12 new documents. self.assertLessEqual( doubled, baseline + 2, f"query count grew from {baseline} to {doubled} when the result set tripled", @@ -387,6 +405,15 @@ def test_prepared_documents_are_picklable(self): shepherd=EmailFactory()) TelechatDocEventFactory(doc=draft) WgRfcFactory() + queued = WgDraftFactory( + states=[("draft", "active"), ("draft-iesg", "rfcqueue"), + ("draft-rfceditor", "in_progress")], + ) + RpcAssignmentDocEvent.objects.create( + doc=queued, rev=queued.rev, by=Person.objects.get(name="(System)"), + type="changed_rpc_assignments", assignments="In Progress (First Edit)", + desc="RPC status changed to In Progress (First Edit)", + ) request = RequestFactory().get("/doc/recent/") request.user = AnonymousUser() @@ -396,6 +423,9 @@ def test_prepared_documents_are_picklable(self): restored, _ = pickle.loads(pickle.dumps([results, meta])) for before, after in zip(results, restored): self.assertEqual(after.telechat_date(), before.telechat_date()) + self.assertEqual( + after.rfc_editor_queue_status(), before.rfc_editor_queue_status() + ) def test_fill_in_telechat_date_matches_the_method(self): """The precomputed value has to equal what Document.telechat_date() returns. @@ -433,6 +463,49 @@ def test_fill_in_telechat_date_matches_the_method(self): self.assertIsNotNone(expected[future.name]) self.assertIsNone(expected[past.name]) + def test_fill_in_rfc_editor_queue_status_matches_the_method(self): + """The precomputed value has to equal what Document.rfc_editor_queue_status() returns. + + The document page renders the method and the document tables render the + precomputed value, so a mismatch shows the same document two different statuses. + """ + system = Person.objects.get(name="(System)") + + def queued(state_slug, *statuses): + doc = WgDraftFactory( + states=[("draft", "active"), ("draft-iesg", "rfcqueue"), + ("draft-rfceditor", state_slug)], + ) + for status in statuses: + RpcAssignmentDocEvent.objects.create( + doc=doc, rev=doc.rev, by=system, type="changed_rpc_assignments", + assignments=status, desc=f"RPC status changed to {status}", + ) + return doc + + in_progress = queued("in_progress", "In Progress (First Edit)") + # A document that has moved on has an event for every status it has held. + moved_on = queued("in_progress", "Awaiting First editor", "In Final Review") + blocked = queued("blocked", "blocked: Manual Hold") + # No queue status: a state from before the queue integration, and no state at all. + legacy = queued("rfc-edit", "In Progress (First Edit)") + not_queued = WgDraftFactory() + + fixtures = (in_progress, moved_on, blocked, legacy, not_queued) + expected = { + d.name: Document.objects.get(pk=d.pk).rfc_editor_queue_status() + for d in fixtures + } + self.assertEqual(expected[moved_on.name], "In Final Review") + self.assertIsNone(expected[legacy.name]) + + docs = list(Document.objects.filter(name__in=[d.name for d in fixtures])) + doc_dict = {d.pk: d for d in docs} + fill_in_rfc_editor_queue_status(docs, doc_dict, list(doc_dict)) + + for doc in docs: + self.assertEqual(doc.rfc_editor_queue_status(), expected[doc.name], doc.name) + def test_search_for_name(self): draft = WgDraftFactory(name='draft-ietf-mars-test',group=GroupFactory(acronym='mars',parent=Group.objects.get(acronym='farfut')),authors=[PersonFactory()],ad=PersonFactory()) draft.set_state(State.objects.get(used=True, type="draft-iesg", slug="pub-req")) @@ -688,6 +761,90 @@ def test_auth48_doc_for_ad(self): self.assertContains(r, draft.name) self.assertContains(r, 'title="AUTH48"') # title attribute of AUTH48 badge in auth48_alert_badge filter + def _status_column_text(self, draft): + r = self.client.get( + urlreverse("ietf.doc.views_search.search") + + f"?activedrafts=on&olddrafts=on&rfcs=on&name={draft.name}" + ) + self.assertEqual(r.status_code, 200) + return PyQuery(r.content)("td.status").text() + + def test_search_labels_the_iesg_state_of_a_queued_draft(self): + """The RFC Ed Queue state is labeled as the IESG's, because the row also shows + the RFC Editor's own status. Other states are left to speak for themselves. + """ + queued = IndividualDraftFactory( + states=[("draft", "active"), ("draft-iesg", "rfcqueue"), + ("draft-rfceditor", "in_progress")] + ) + queue_state_name = queued.get_state("draft-iesg").name + self.assertIn(f"IESG: {queue_state_name}", self._status_column_text(queued)) + # The document page has a row labeled "IESG state" already, so the value there + # is the bare state name. + r = self.client.get( + urlreverse("ietf.doc.views_doc.document_main", + kwargs=dict(name=queued.name)) + ) + self.assertEqual(r.status_code, 200) + self.assertContains(r, queue_state_name) + self.assertNotContains(r, f"IESG: {queue_state_name}") + + for slug, states in ( + ("iesg-eva", [("draft", "active"), ("draft-iesg", "iesg-eva")]), + ("idexists", [("draft", "active"), ("draft-iesg", "idexists")]), + ("expired", [("draft", "expired"), ("draft-iesg", "idexists")]), + ): + with self.subTest(slug=slug): + draft = IndividualDraftFactory(states=states) + self.assertNotIn("IESG: ", self._status_column_text(draft)) + + # friendly_state labels this one itself; it must not be labeled twice. + dead = IndividualDraftFactory( + states=[("draft", "active"), ("draft-iesg", "dead")] + ) + self.assertIn("I-D Exists (IESG: Dead)", self._status_column_text(dead)) + + def test_search_shows_rfc_editor_queue_status(self): + """A queued draft's row shows the publication queue Status, as its document page does. + + The status column carries two state machines at once, so each state is labeled + with the body it belongs to. + """ + draft = IndividualDraftFactory( + states=[ + ("draft", "active"), + ("draft-iesg", "rfcqueue"), + ("draft-rfceditor", "in_progress"), + ] + ) + RpcAssignmentDocEvent.objects.create( + doc=draft, + rev=draft.rev, + by=Person.objects.get(name="(System)"), + type="changed_rpc_assignments", + assignments="In Progress (First Edit)", + desc="RPC status changed to In Progress (First Edit)", + ) + status = self._status_column_text(draft) + self.assertIn(f"IESG: {draft.get_state('draft-iesg').name}", status) + self.assertIn("RFC Editor: In Progress (First Edit)", status) + + def test_search_falls_back_to_rfc_editor_state_name(self): + """A document with no queue status shows its draft-rfceditor state name. + + Documents that went through the RFC Editor before the publication queue + integration have a draft-rfceditor state but no RpcAssignmentDocEvent. + """ + draft = IndividualDraftFactory( + states=[ + ("draft", "active"), + ("draft-iesg", "rfcqueue"), + ("draft-rfceditor", "rfc-edit"), + ] + ) + status = self._status_column_text(draft) + self.assertIn(f"RFC Editor: {draft.get_state('draft-rfceditor').name}", status) + def test_drafts_in_last_call(self): draft = IndividualDraftFactory(pages=1) draft.action_holders.set([PersonFactory()]) diff --git a/ietf/doc/utils_search.py b/ietf/doc/utils_search.py index 267c51aeee7..932e070412c 100644 --- a/ietf/doc/utils_search.py +++ b/ietf/doc/utils_search.py @@ -10,7 +10,8 @@ from django.conf import settings -from ietf.doc.models import Document, RelatedDocument, DocEvent, TelechatDocEvent, BallotDocEvent, DocTypeName +from ietf.doc.models import (Document, RelatedDocument, DocEvent, TelechatDocEvent, BallotDocEvent, + DocTypeName, RpcAssignmentDocEvent) from ietf.doc.expire import expirable_drafts from ietf.doc.utils import augment_docs_and_person_with_person_info from ietf.meeting.models import SessionPresentation, Meeting, Session @@ -159,6 +160,34 @@ def reachable_from(start): d.related_ipr = wrap_value(sorted(related)) +def fill_in_rfc_editor_queue_status(docs, doc_dict, doc_ids): + """Attach each document's RFC Editor publication queue status. + + The status column shows it for every document sitting in the RFC Editor queue, and + Document.rfc_editor_queue_status() costs a query per such row to find the latest + RpcAssignmentDocEvent. Here they take one query between them. + """ + queued_ids = [ + d.pk + for d in docs + if d.get_state_slug("draft-rfceditor") in ("in_progress", "blocked") + ] + # Wrapped rather than assigned bare so the attribute stays callable, like the + # Document.rfc_editor_queue_status method it shadows. + for d in docs: + d.rfc_editor_queue_status = wrap_value(None) + if not queued_ids: + return + + # DISTINCT ON fetches only the newest event per document; a document that has moved + # through the queue has one for every status it has held. + for e in (RpcAssignmentDocEvent.objects + .filter(doc_id__in=queued_ids, type="changed_rpc_assignments") + .order_by("doc_id", "-time", "-id") + .distinct("doc_id")): + doc_dict[e.doc_id].rfc_editor_queue_status = wrap_value(e.assignments) + + def fill_in_person_caches(docs): """Seed the per-instance caches person_link and email_person_link read. @@ -288,6 +317,7 @@ def fill_in_document_table_attributes(docs, have_telechat_date=False): fill_in_document_relations(docs, doc_dict, doc_ids) fill_in_related_ipr(docs, doc_dict, doc_ids) + fill_in_rfc_editor_queue_status(docs, doc_dict, doc_ids) fill_in_person_caches(docs) if not have_telechat_date: diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 1472c808eb0..4d2edbea5c1 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -60,7 +60,7 @@ from ietf.doc.models import ( Document, DocHistory, DocEvent, BallotDocEvent, BallotType, ConsensusDocEvent, NewRevisionDocEvent, StoredObject, TelechatDocEvent, WriteupDocEvent, IanaExpertDocEvent, IESG_BALLOT_ACTIVE_STATES, STATUSCHANGE_RELATIONS, DocumentActionHolder, DocumentAuthor, - RelatedDocument, RelatedDocHistory, RpcAssignmentDocEvent) + RelatedDocument, RelatedDocHistory) from ietf.doc.tasks import investigate_fragment_task from ietf.doc.utils import (augment_events_with_revision, can_adopt_draft, can_unadopt_draft, get_chartering_type, get_tags_for_stream_id, @@ -198,21 +198,6 @@ def interesting_doc_relations(doc): return interesting_relations_that, interesting_relations_that_doc -def rfc_editor_queue_status(doc): - """Human-readable RPC publication queue "Status" for the document, or None. - - While a document is in the RFC Editor queue (draft-rfceditor state - "in_progress" or "blocked"), this is the status text pushed by the RFC - Production Center, matching what the queue website shows. It is displayed in - place of the raw draft-rfceditor state name. Returns None for documents whose - draft-rfceditor state predates the queue integration (they fall back to the - state name). - """ - if doc.get_state_slug("draft-rfceditor") not in ("in_progress", "blocked"): - return None - event = doc.latest_event(RpcAssignmentDocEvent, type="changed_rpc_assignments") - return event.assignments if event else None - def document_main(request, name, rev=None, document_html=False): doc = get_object_or_404(Document.objects.select_related(), name=name) @@ -380,7 +365,7 @@ def document_main(request, name, rev=None, document_html=False): has_errata=doc.pk and doc.tags.filter(slug="errata"), # doc.pk == None if using a fake_history_obj file_urls=file_urls, rfc_editor_state=doc.get_state("draft-rfceditor"), - rfc_editor_queue_status=rfc_editor_queue_status(doc), + rfc_editor_queue_status=doc.rfc_editor_queue_status(), iana_review_state=doc.get_state("draft-iana-review"), iana_action_state=doc.get_state("draft-iana-action"), iana_experts_state=doc.get_state("draft-iana-experts"), @@ -406,7 +391,8 @@ def document_main(request, name, rev=None, document_html=False): if isinstance(doc, Document): log.assertion('iesg_state', note="A document's 'draft-iesg' state should never be unset'. Failed for %s"%doc.name) iesg_state_slug = iesg_state.slug if iesg_state else None - iesg_state_summary = doc.friendly_state() + # Rendered in a row of its own labeled "IESG state", so not labeled again here. + iesg_state_summary = doc.friendly_state(label_iesg_state=False) irsg_state = doc.get_state("draft-stream-irtf") can_edit = has_role(request.user, ("Area Director", "Secretariat")) @@ -724,7 +710,7 @@ def document_main(request, name, rev=None, document_html=False): iesg_state=iesg_state, iesg_state_summary=iesg_state_summary, rfc_editor_state=doc.get_state("draft-rfceditor"), - rfc_editor_queue_status=rfc_editor_queue_status(doc), + rfc_editor_queue_status=doc.rfc_editor_queue_status(), rfc_editor_auth48_url=auth48_url, iana_review_state=doc.get_state("draft-iana-review"), iana_action_state=doc.get_state("draft-iana-action"), diff --git a/ietf/doc/views_review.py b/ietf/doc/views_review.py index 1968b133ce1..17eb4d40b47 100644 --- a/ietf/doc/views_review.py +++ b/ietf/doc/views_review.py @@ -249,7 +249,8 @@ def review_request(request, name, request_id): if review_req.doc.group: wg_chairs = [role.person for role in review_req.doc.group.role_set.filter(name__slug='chair')] - iesg_state_summary = review_req.doc.friendly_state() + # Rendered in a row of its own labeled "IESG document state", so not labeled again here. + iesg_state_summary = review_req.doc.friendly_state(label_iesg_state=False) history = list(review_req.history.all()) history += itertools.chain(*[list(r.history.all()) for r in review_req.reviewassignment_set.all()]) diff --git a/ietf/templates/doc/search/status_columns.html b/ietf/templates/doc/search/status_columns.html index 50d70fcb379..353abf917d1 100644 --- a/ietf/templates/doc/search/status_columns.html +++ b/ietf/templates/doc/search/status_columns.html @@ -5,16 +5,17 @@
{% ballot_icon doc %}
{% if not doc.type_id == "rfc" %} - {% if '::' in doc.friendly_state %} - {{ doc.friendly_state|safe }} - {% else %} - {{ doc.friendly_state|safe }} - {% endif %} - {% if doc|state:"draft-rfceditor" %} - : {{ doc|state:"draft-rfceditor" }} - {% endif %} + {{ doc.friendly_state|safe }} {{ doc|auth48_alert_badge }} {{ doc|state_age_colored }} + {# The RFC Editor runs a state machine of its own, so its status is labeled rather than read as a substate of the state above. #} + {% with rfc_editor_state=doc|state:"draft-rfceditor" %} + {% if rfc_editor_state %} +
+ RFC Editor: + {{ doc.rfc_editor_queue_status|default:rfc_editor_state }} + {% endif %} + {% endwith %} {% if doc.telechat_date %}
IESG telechat: {{ doc.telechat_date }} From 898f6226d45ad2d19039d7da4e6498253dcf5859 Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Tue, 18 Aug 2026 09:01:23 -0500 Subject: [PATCH 2/2] docs: commentary on prefetch states and tags for iesg agenda --- ietf/doc/utils_search.py | 5 +++++ ietf/iesg/views.py | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ietf/doc/utils_search.py b/ietf/doc/utils_search.py index 932e070412c..45108debb18 100644 --- a/ietf/doc/utils_search.py +++ b/ietf/doc/utils_search.py @@ -166,6 +166,11 @@ def fill_in_rfc_editor_queue_status(docs, doc_dict, doc_ids): The status column shows it for every document sitting in the RFC Editor queue, and Document.rfc_editor_queue_status() costs a query per such row to find the latest RpcAssignmentDocEvent. Here they take one query between them. + + Finding which documents are queued reads each document's states, which this assumes + is free -- either prefetched by the caller, as prepare_document_table() does, or + already cached on the instances by an earlier get_state(). Absent both, that read + costs a query per document, which is the sort of per-row cost this exists to avoid. """ queued_ids = [ d.pk diff --git a/ietf/iesg/views.py b/ietf/iesg/views.py index af04ad06fd5..d65d923ad9c 100644 --- a/ietf/iesg/views.py +++ b/ietf/iesg/views.py @@ -383,7 +383,9 @@ def agenda_documents(request): docs_by_date = dict((d, []) for d in dates) docs = Document.objects.filter(docevent__telechatdocevent__telechat_date__in=dates).distinct() docs = docs.select_related("ad", "std_level", "intended_std_level", "group", "stream", "shepherd", ) - # No prefetch-related -- turns out not to be worth it + # The states and tags the status column of every row reads, each of which otherwise + # costs a query per document. + docs = docs.prefetch_related("states__type", "tags") fill_in_telechat_date(docs) for doc in docs: