Skip to content

Commit 3f84a81

Browse files
authored
feat: show rfc editor queue status on search result rows (ietf-tools#11549)
* feat: show rfc editor queue status on search result rows * docs: commentary on prefetch states and tags for iesg agenda
1 parent 76a9af9 commit 3f84a81

7 files changed

Lines changed: 250 additions & 39 deletions

File tree

ietf/doc/models.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -401,8 +401,33 @@ def get_state_slug(self, state_type=None):
401401
self._cached_state_slug[state_type] = s.slug if s else None
402402
return self._cached_state_slug[state_type]
403403

404-
def friendly_state(self):
405-
""" Return a concise text description of the document's current state."""
404+
def rfc_editor_queue_status(self):
405+
"""Human-readable RPC publication queue "Status" for the document, or None.
406+
407+
While a document is in the RFC Editor queue (draft-rfceditor state
408+
"in_progress" or "blocked"), this is the status text pushed by the RFC
409+
Production Center, matching what the queue website shows. Displays of the RFC
410+
Editor state show it in place of the state name. It is None for a document whose
411+
draft-rfceditor state predates the queue integration, which leaves those displays
412+
showing the state name itself.
413+
414+
Costs a query per call. Tables rendering many documents replace this with a
415+
precomputed value; see ietf.doc.utils_search.fill_in_rfc_editor_queue_status.
416+
"""
417+
if self.get_state_slug("draft-rfceditor") not in ("in_progress", "blocked"):
418+
return None
419+
event = self.latest_event(RpcAssignmentDocEvent, type="changed_rpc_assignments")
420+
return event.assignments if event else None
421+
422+
def friendly_state(self, label_iesg_state=True):
423+
""" Return a concise text description of the document's current state.
424+
425+
For a draft in the RFC Editor queue that description is "IESG: RFC Ed Queue",
426+
labeled because displays of it sit next to the RFC Editor's own status for the
427+
document. Every other state stands on its own and is returned unlabeled. Callers
428+
rendering the description somewhere already labeled as the IESG's pass
429+
label_iesg_state=False.
430+
"""
406431
state = self.get_state()
407432
if not state:
408433
return "Unknown state"
@@ -440,7 +465,11 @@ def friendly_state(self):
440465
e = self.latest_event(LastCallDocEvent, type="sent_last_call")
441466
if e:
442467
return iesg_state_summary + " (ends %s)" % e.expires.astimezone(DEADLINE_TZINFO).date().isoformat()
443-
468+
elif label_iesg_state and iesg_state.slug == "rfcqueue":
469+
# The only state whose displays sit next to the RFC Editor's own
470+
# status for the document, where a bare state name is ambiguous.
471+
return "IESG: %s" % iesg_state_summary
472+
444473
return iesg_state_summary
445474
else:
446475
return "I-D Exists"

ietf/doc/tests.py

Lines changed: 163 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@
8686
from ietf.utils.test_utils import TestCase
8787
from ietf.utils.text import normalize_text, texescape
8888
from ietf.utils.timezone import date_today, datetime_today, DEADLINE_TZINFO, RPC_TZINFO
89-
from ietf.doc.utils_search import AD_WORKLOAD, fill_in_telechat_date, prepare_document_table
89+
from ietf.doc.utils_search import (AD_WORKLOAD, fill_in_rfc_editor_queue_status,
90+
fill_in_telechat_date, prepare_document_table)
9091

9192

9293
class SearchTests(TestCase):
@@ -308,21 +309,38 @@ def test_search_query_count_does_not_grow_with_results(self):
308309
so doubling the number of rows must not change the number of queries. A lookup
309310
that slipped back into the per-row path shows up here as a count that grows.
310311
311-
Mind the blind spots: these documents have no IESG state, ballot, last call,
312-
action holders, telechat or obsoleting RFCs, so the per-row work the columns
313-
driven by those still do is not covered. Widen the fixtures rather than reading
314-
a pass here as "the table does no per-row queries".
312+
Mind the blind spots: these documents have no ballot, last call, action holders,
313+
telechat, obsoleting RFCs, or IESG state under way, so the per-row work the
314+
columns driven by those still do is not covered -- state_age_colored and the
315+
action holder list each still cost a query for a document being processed by the
316+
IESG. Widen the fixtures rather than reading a pass here as "the table does no
317+
per-row queries".
315318
"""
316319
group = GroupFactory(type_id="wg")
317320
url = urlreverse('ietf.doc.views_search.search') + (
318321
f"?activedrafts=on&olddrafts=on&rfcs=on&by=group&group={group.acronym}"
319322
)
323+
system = Person.objects.get(name="(System)")
320324

321325
def add_documents(count):
322326
for _ in range(count):
323327
WgDraftFactory(group=group, authors=[PersonFactory()], ad=PersonFactory(),
324328
shepherd=EmailFactory())
325329
WgRfcFactory(group=group)
330+
# A draft in the RFC Editor queue, whose row also shows the queue status.
331+
# Not one the IESG is processing, to keep the blind spots above out of
332+
# the count.
333+
queued = WgDraftFactory(
334+
group=group,
335+
states=[("draft", "active"), ("draft-iesg", "idexists"),
336+
("draft-rfceditor", "in_progress")],
337+
)
338+
RpcAssignmentDocEvent.objects.create(
339+
doc=queued, rev=queued.rev, by=system,
340+
type="changed_rpc_assignments",
341+
assignments="In Progress (First Edit)",
342+
desc="RPC status changed to In Progress (First Edit)",
343+
)
326344

327345
def count_queries():
328346
with CaptureQueriesContext(connection) as context:
@@ -335,7 +353,7 @@ def count_queries():
335353
add_documents(4)
336354
doubled = count_queries()
337355

338-
# A per-row lookup would add at least one query for each of the 8 new documents.
356+
# A per-row lookup would add at least one query for each of the 12 new documents.
339357
self.assertLessEqual(
340358
doubled, baseline + 2,
341359
f"query count grew from {baseline} to {doubled} when the result set tripled",
@@ -387,6 +405,15 @@ def test_prepared_documents_are_picklable(self):
387405
shepherd=EmailFactory())
388406
TelechatDocEventFactory(doc=draft)
389407
WgRfcFactory()
408+
queued = WgDraftFactory(
409+
states=[("draft", "active"), ("draft-iesg", "rfcqueue"),
410+
("draft-rfceditor", "in_progress")],
411+
)
412+
RpcAssignmentDocEvent.objects.create(
413+
doc=queued, rev=queued.rev, by=Person.objects.get(name="(System)"),
414+
type="changed_rpc_assignments", assignments="In Progress (First Edit)",
415+
desc="RPC status changed to In Progress (First Edit)",
416+
)
390417

391418
request = RequestFactory().get("/doc/recent/")
392419
request.user = AnonymousUser()
@@ -396,6 +423,9 @@ def test_prepared_documents_are_picklable(self):
396423
restored, _ = pickle.loads(pickle.dumps([results, meta]))
397424
for before, after in zip(results, restored):
398425
self.assertEqual(after.telechat_date(), before.telechat_date())
426+
self.assertEqual(
427+
after.rfc_editor_queue_status(), before.rfc_editor_queue_status()
428+
)
399429

400430
def test_fill_in_telechat_date_matches_the_method(self):
401431
"""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):
433463
self.assertIsNotNone(expected[future.name])
434464
self.assertIsNone(expected[past.name])
435465

466+
def test_fill_in_rfc_editor_queue_status_matches_the_method(self):
467+
"""The precomputed value has to equal what Document.rfc_editor_queue_status() returns.
468+
469+
The document page renders the method and the document tables render the
470+
precomputed value, so a mismatch shows the same document two different statuses.
471+
"""
472+
system = Person.objects.get(name="(System)")
473+
474+
def queued(state_slug, *statuses):
475+
doc = WgDraftFactory(
476+
states=[("draft", "active"), ("draft-iesg", "rfcqueue"),
477+
("draft-rfceditor", state_slug)],
478+
)
479+
for status in statuses:
480+
RpcAssignmentDocEvent.objects.create(
481+
doc=doc, rev=doc.rev, by=system, type="changed_rpc_assignments",
482+
assignments=status, desc=f"RPC status changed to {status}",
483+
)
484+
return doc
485+
486+
in_progress = queued("in_progress", "In Progress (First Edit)")
487+
# A document that has moved on has an event for every status it has held.
488+
moved_on = queued("in_progress", "Awaiting First editor", "In Final Review")
489+
blocked = queued("blocked", "blocked: Manual Hold")
490+
# No queue status: a state from before the queue integration, and no state at all.
491+
legacy = queued("rfc-edit", "In Progress (First Edit)")
492+
not_queued = WgDraftFactory()
493+
494+
fixtures = (in_progress, moved_on, blocked, legacy, not_queued)
495+
expected = {
496+
d.name: Document.objects.get(pk=d.pk).rfc_editor_queue_status()
497+
for d in fixtures
498+
}
499+
self.assertEqual(expected[moved_on.name], "In Final Review")
500+
self.assertIsNone(expected[legacy.name])
501+
502+
docs = list(Document.objects.filter(name__in=[d.name for d in fixtures]))
503+
doc_dict = {d.pk: d for d in docs}
504+
fill_in_rfc_editor_queue_status(docs, doc_dict, list(doc_dict))
505+
506+
for doc in docs:
507+
self.assertEqual(doc.rfc_editor_queue_status(), expected[doc.name], doc.name)
508+
436509
def test_search_for_name(self):
437510
draft = WgDraftFactory(name='draft-ietf-mars-test',group=GroupFactory(acronym='mars',parent=Group.objects.get(acronym='farfut')),authors=[PersonFactory()],ad=PersonFactory())
438511
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):
688761
self.assertContains(r, draft.name)
689762
self.assertContains(r, 'title="AUTH48"') # title attribute of AUTH48 badge in auth48_alert_badge filter
690763

764+
def _status_column_text(self, draft):
765+
r = self.client.get(
766+
urlreverse("ietf.doc.views_search.search")
767+
+ f"?activedrafts=on&olddrafts=on&rfcs=on&name={draft.name}"
768+
)
769+
self.assertEqual(r.status_code, 200)
770+
return PyQuery(r.content)("td.status").text()
771+
772+
def test_search_labels_the_iesg_state_of_a_queued_draft(self):
773+
"""The RFC Ed Queue state is labeled as the IESG's, because the row also shows
774+
the RFC Editor's own status. Other states are left to speak for themselves.
775+
"""
776+
queued = IndividualDraftFactory(
777+
states=[("draft", "active"), ("draft-iesg", "rfcqueue"),
778+
("draft-rfceditor", "in_progress")]
779+
)
780+
queue_state_name = queued.get_state("draft-iesg").name
781+
self.assertIn(f"IESG: {queue_state_name}", self._status_column_text(queued))
782+
# The document page has a row labeled "IESG state" already, so the value there
783+
# is the bare state name.
784+
r = self.client.get(
785+
urlreverse("ietf.doc.views_doc.document_main",
786+
kwargs=dict(name=queued.name))
787+
)
788+
self.assertEqual(r.status_code, 200)
789+
self.assertContains(r, queue_state_name)
790+
self.assertNotContains(r, f"IESG: {queue_state_name}")
791+
792+
for slug, states in (
793+
("iesg-eva", [("draft", "active"), ("draft-iesg", "iesg-eva")]),
794+
("idexists", [("draft", "active"), ("draft-iesg", "idexists")]),
795+
("expired", [("draft", "expired"), ("draft-iesg", "idexists")]),
796+
):
797+
with self.subTest(slug=slug):
798+
draft = IndividualDraftFactory(states=states)
799+
self.assertNotIn("IESG: ", self._status_column_text(draft))
800+
801+
# friendly_state labels this one itself; it must not be labeled twice.
802+
dead = IndividualDraftFactory(
803+
states=[("draft", "active"), ("draft-iesg", "dead")]
804+
)
805+
self.assertIn("I-D Exists (IESG: Dead)", self._status_column_text(dead))
806+
807+
def test_search_shows_rfc_editor_queue_status(self):
808+
"""A queued draft's row shows the publication queue Status, as its document page does.
809+
810+
The status column carries two state machines at once, so each state is labeled
811+
with the body it belongs to.
812+
"""
813+
draft = IndividualDraftFactory(
814+
states=[
815+
("draft", "active"),
816+
("draft-iesg", "rfcqueue"),
817+
("draft-rfceditor", "in_progress"),
818+
]
819+
)
820+
RpcAssignmentDocEvent.objects.create(
821+
doc=draft,
822+
rev=draft.rev,
823+
by=Person.objects.get(name="(System)"),
824+
type="changed_rpc_assignments",
825+
assignments="In Progress (First Edit)",
826+
desc="RPC status changed to In Progress (First Edit)",
827+
)
828+
status = self._status_column_text(draft)
829+
self.assertIn(f"IESG: {draft.get_state('draft-iesg').name}", status)
830+
self.assertIn("RFC Editor: In Progress (First Edit)", status)
831+
832+
def test_search_falls_back_to_rfc_editor_state_name(self):
833+
"""A document with no queue status shows its draft-rfceditor state name.
834+
835+
Documents that went through the RFC Editor before the publication queue
836+
integration have a draft-rfceditor state but no RpcAssignmentDocEvent.
837+
"""
838+
draft = IndividualDraftFactory(
839+
states=[
840+
("draft", "active"),
841+
("draft-iesg", "rfcqueue"),
842+
("draft-rfceditor", "rfc-edit"),
843+
]
844+
)
845+
status = self._status_column_text(draft)
846+
self.assertIn(f"RFC Editor: {draft.get_state('draft-rfceditor').name}", status)
847+
691848
def test_drafts_in_last_call(self):
692849
draft = IndividualDraftFactory(pages=1)
693850
draft.action_holders.set([PersonFactory()])

ietf/doc/utils_search.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010

1111
from django.conf import settings
1212

13-
from ietf.doc.models import Document, RelatedDocument, DocEvent, TelechatDocEvent, BallotDocEvent, DocTypeName
13+
from ietf.doc.models import (Document, RelatedDocument, DocEvent, TelechatDocEvent, BallotDocEvent,
14+
DocTypeName, RpcAssignmentDocEvent)
1415
from ietf.doc.expire import expirable_drafts
1516
from ietf.doc.utils import augment_docs_and_person_with_person_info
1617
from ietf.meeting.models import SessionPresentation, Meeting, Session
@@ -159,6 +160,39 @@ def reachable_from(start):
159160
d.related_ipr = wrap_value(sorted(related))
160161

161162

163+
def fill_in_rfc_editor_queue_status(docs, doc_dict, doc_ids):
164+
"""Attach each document's RFC Editor publication queue status.
165+
166+
The status column shows it for every document sitting in the RFC Editor queue, and
167+
Document.rfc_editor_queue_status() costs a query per such row to find the latest
168+
RpcAssignmentDocEvent. Here they take one query between them.
169+
170+
Finding which documents are queued reads each document's states, which this assumes
171+
is free -- either prefetched by the caller, as prepare_document_table() does, or
172+
already cached on the instances by an earlier get_state(). Absent both, that read
173+
costs a query per document, which is the sort of per-row cost this exists to avoid.
174+
"""
175+
queued_ids = [
176+
d.pk
177+
for d in docs
178+
if d.get_state_slug("draft-rfceditor") in ("in_progress", "blocked")
179+
]
180+
# Wrapped rather than assigned bare so the attribute stays callable, like the
181+
# Document.rfc_editor_queue_status method it shadows.
182+
for d in docs:
183+
d.rfc_editor_queue_status = wrap_value(None)
184+
if not queued_ids:
185+
return
186+
187+
# DISTINCT ON fetches only the newest event per document; a document that has moved
188+
# through the queue has one for every status it has held.
189+
for e in (RpcAssignmentDocEvent.objects
190+
.filter(doc_id__in=queued_ids, type="changed_rpc_assignments")
191+
.order_by("doc_id", "-time", "-id")
192+
.distinct("doc_id")):
193+
doc_dict[e.doc_id].rfc_editor_queue_status = wrap_value(e.assignments)
194+
195+
162196
def fill_in_person_caches(docs):
163197
"""Seed the per-instance caches person_link and email_person_link read.
164198
@@ -288,6 +322,7 @@ def fill_in_document_table_attributes(docs, have_telechat_date=False):
288322

289323
fill_in_document_relations(docs, doc_dict, doc_ids)
290324
fill_in_related_ipr(docs, doc_dict, doc_ids)
325+
fill_in_rfc_editor_queue_status(docs, doc_dict, doc_ids)
291326
fill_in_person_caches(docs)
292327

293328
if not have_telechat_date:

0 commit comments

Comments
 (0)