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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions ietf/doc/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
169 changes: 163 additions & 6 deletions ietf/doc/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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",
Expand Down Expand Up @@ -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()
Expand All @@ -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.
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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()])
Expand Down
37 changes: 36 additions & 1 deletion ietf/doc/utils_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -159,6 +160,39 @@ 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.

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
for d in docs
if d.get_state_slug("draft-rfceditor") in ("in_progress", "blocked")
Comment thread
jennifer-richards marked this conversation as resolved.
]
# 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.

Expand Down Expand Up @@ -288,6 +322,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:
Expand Down
Loading
Loading