From b70fb153b5abd16d4c945823e276bd82aa3e6769 Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Thu, 6 Aug 2026 19:30:44 -0500 Subject: [PATCH] feat: show same RFC Editor Queue status values as queue.rfc-editor.org (#11236) --- ietf/doc/tests.py | 41 +++++- ietf/doc/views_doc.py | 20 ++- ietf/doc/views_help.py | 55 ++++++++ ietf/sync/tasks.py | 94 ++++++++++++-- ietf/sync/tests_tasks.py | 171 +++++++++++++++++++++++-- ietf/templates/doc/document_draft.html | 4 +- ietf/templates/doc/state_help.html | 31 +++++ 7 files changed, 391 insertions(+), 25 deletions(-) diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index 86731000730..6f15003f92f 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -38,7 +38,7 @@ from ietf.doc.models import (Document, DocRelationshipName, RelatedDocument, State, DocEvent, BallotPositionDocEvent, LastCallDocEvent, WriteupDocEvent, NewRevisionDocEvent, BallotType, - EditedAuthorsDocEvent, StateType, RfcAuthor) + EditedAuthorsDocEvent, StateType, RfcAuthor, RpcAssignmentDocEvent) from ietf.doc.factories import (DocumentFactory, DocEventFactory, CharterFactory, ConflictReviewFactory, WgDraftFactory, IndividualDraftFactory, WgRfcFactory, @@ -1688,6 +1688,28 @@ def _change_state(doc, state): self.assertEqual(r.status_code, 200) self.assertNotContains(r, 'Auth48 status') + def test_rfceditor_queue_status_shown(self): + """A queued draft shows its publication-queue Status in place of the state name.""" + draft = IndividualDraftFactory() + event = StateDocEventFactory(doc=draft, state=('draft-rfceditor', 'in_progress')) + draft.set_state(event.state) + draft.save_with_history([event]) + 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)", + ) + + r = self.client.get(urlreverse("ietf.doc.views_doc.document_main", kwargs=dict(name=draft.name))) + self.assertEqual(r.status_code, 200) + # The composite queue Status (which only comes from the RpcAssignmentDocEvent, + # not from the state name) is shown, confirming it replaces the raw state name. + self.assertContains(r, "In Progress (First Edit)") + self.assertContains(r, "Publication queue entry") + class DocTestCase(TestCase): def test_status_change(self): @@ -2103,6 +2125,23 @@ def test_state_help(self): self.assertEqual(r.status_code, 200) self.assertContains(r, State.objects.get(type="draft-iesg", slug="lc").name) + def test_rfceditor_state_help_has_queue_status_and_legacy_sections(self): + url = urlreverse('ietf.doc.views_help.state_help', kwargs=dict(type="draft-rfceditor")) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + # New "Queue status" section describing the queue Status values. + self.assertContains(r, "Queue status") + self.assertContains(r, "In Progress (First Edit)") + # Legacy states are moved to their own section with the history note. + self.assertContains(r, "Legacy states") + self.assertContains(r, "appear in the change history") + self.assertContains(r, State.objects.get(type="draft-rfceditor", slug="auth48").name) + # The queue-backing states are not listed among the legacy states. + q = PyQuery(r.content) + legacy_ids = [row.get("id") for row in q("tbody tr")] + self.assertNotIn("in_progress", legacy_ids) + self.assertNotIn("blocked", legacy_ids) + def test_document_nonietf_pubreq_button(self): doc = IndividualDraftFactory() diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index af056f6a96b..1472c808eb0 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) + RelatedDocument, RelatedDocHistory, RpcAssignmentDocEvent) 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, @@ -197,6 +197,22 @@ 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) @@ -364,6 +380,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), 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"), @@ -707,6 +724,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_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_help.py b/ietf/doc/views_help.py index 34d29aaccbc..1b6c86394d6 100644 --- a/ietf/doc/views_help.py +++ b/ietf/doc/views_help.py @@ -9,6 +9,43 @@ from ietf.name.models import DocRelationshipName, DocTagName from ietf.doc.utils import get_tags_for_stream_id +# Documentation of the values shown in the RFC Editor queue "Status" field. This +# status is not a stored state; it is derived by the RFC Production Center's +# publication queue from the active editor assignments, pending activities, +# blocking reasons and IANA status of a document, and is rendered to match the +# publication queue site (https://queue.rfc-editor.org/). Keep in sync with +# ietf.sync.tasks.format_rpc_queue_status. +RFC_EDITOR_QUEUE_STATUS_VALUES = [ + ("In Progress (First Edit)", + "The document is being copyedited by the first editor."), + ("In Progress (Second Edit)", + "The document is getting a second review, focusing on complex issues and IANA " + "actions."), + ("In Final Review", + "Awaiting final approval(s) from authors and/or action holders."), + (" (e.g. “formatting”)", + "Another RPC activity is currently underway for the document; the activity is " + "shown by name (for example “formatting”). Reference checking and " + "publication are not shown as their own status."), + ("Awaiting ", + "The document is in the queue waiting for the named activity to begin. Values " + "include “Awaiting Formatting”, “Awaiting Reference Checker”, " + "“Awaiting First editor”, “Awaiting Second editor”, " + "“Awaiting Final review editor”, and “Awaiting Publisher”."), + ("Awaiting Editor Assignment", + "The document is in the queue but nothing has been assigned to it yet."), + ("IANA hold", + "First editing is underway but is held pending completion of IANA actions. (This " + "is distinct from the “IANA Hold” blocking reason below.)"), + ("blocked: ", + "Progress is blocked; one or more blocking reasons are listed after the colon. " + "The possible reasons are: Waiting for Action Holder, Stream Hold, External " + "Reference Hold, Author Input Required, IANA Hold, Reference Not Received, " + "Reference Not Received (2nd Generation), Reference Not Received (3rd Generation), " + "Reference: Second Edit Incomplete, Reference: Publish Incomplete, Final Approval " + "Pending, Tools Issue, and Manual Hold."), +] + def state_index(request): types = StateType.objects.all() names = [ type.slug for type in types ] @@ -67,6 +104,22 @@ def state_help(request, type=None): states = State.objects.filter(used=True, type=state_type).order_by("order") + # The RFC Editor queue status is now driven by the RFC Production Center's + # publication queue rather than by the legacy draft-rfceditor states. The + # "in_progress"/"blocked" states back the queue-status display; the remaining + # states are legacy and only appear in the history of older documents. + queue_status_values = None + legacy_states_note = None + if state_type.slug == "draft-rfceditor": + states = states.exclude(slug__in=("in_progress", "blocked")) + queue_status_values = RFC_EDITOR_QUEUE_STATUS_VALUES + legacy_states_note = ( + "These states predate the current RFC Editor publication queue and are " + "no longer assigned to documents. They are documented here because they " + "still appear in the change history of documents that were processed " + "before the queue integration." + ) + has_next_states = False for state in states: if state.next_states.all(): @@ -88,6 +141,8 @@ def state_help(request, type=None): "states": states, "has_next_states": has_next_states, "tags": tags, + "queue_status_values": queue_status_values, + "legacy_states_note": legacy_states_note, } ) def relationship_help(request,subset=None): diff --git a/ietf/sync/tasks.py b/ietf/sync/tasks.py index 3af5cb8984d..24d3c77b3be 100644 --- a/ietf/sync/tasks.py +++ b/ietf/sync/tasks.py @@ -299,6 +299,89 @@ def refresh_rfc_index_task(): mark_rfcindex_as_processed(new_processed_time) +# Human-readable labels for the RPC publication queue "Status", mirroring the +# ietf-tools/queue website (website/app/utils/queue.ts, renderAssignmentsByRoles) +# so the datatracker shows the same status text that appears at +# https://queue.rfc-editor.org/. The queue "Status" is not a stored field; it is +# derived from the active assignment roles, pending activities, blocking reasons +# and IANA status carried in the purple pubq queue payload. +RPC_QUEUE_ROLE_LABELS = { + "first_editor": "In Progress (First Edit)", + "second_editor": "In Progress (Second Edit)", + "final_review_editor": "In Final Review", +} +# Roles the queue site does not surface in the Status column. +RPC_QUEUE_HIDDEN_ROLES = {"ref_checker", "publisher"} + + +def _humanize_slug(slug): + return slug.replace("_", " ") + + +def _rpc_role_label(role): + return RPC_QUEUE_ROLE_LABELS.get(role, _humanize_slug(role)) + + +def _rpc_blocking_reason_label(name): + # Special case mirrored from the queue site's humanFriendlyBlockingReason(). + if name == "Reference: First Edit Incomplete": + return "Author Input Required" + return _humanize_slug(name) + + +def format_rpc_queue_status(obj): + """Render the RPC publication queue "Status" for a single queue entry. + + Mirrors renderAssignmentsByRoles() from the ietf-tools/queue website so the + datatracker presents the same status text. ``obj`` is one entry of the purple + pubq queue payload. Roles, pending activities and blocking reasons are sorted + so the result is stable (a change to the string is what triggers a new + RpcAssignmentDocEvent). + """ + roles = { + a["role"] for a in (obj.get("assignment_set") or []) if a.get("role") + } + is_blocked = "blocked" in roles + + parts = [] + + # IANA hold: iana_status "not_completed" while a first_editor is assigned. + iana_status = obj.get("iana_status") or {} + if iana_status.get("slug") == "not_completed" and "first_editor" in roles: + parts.append("IANA hold") + + # Pending activities (only when not blocked): "Awaiting ", skipping any + # role that is already a current assignment. Note the queue site does NOT hide + # ref_checker/publisher here (only for current-role badges below), so e.g. + # "Awaiting Reference Checker" can appear. + if not is_blocked: + for activity in sorted( + obj.get("pending_activities") or [], + key=lambda a: (a.get("name") or a.get("slug") or ""), + ): + slug = activity.get("slug") + if not slug or slug in roles: + continue + parts.append(f"Awaiting {activity.get('name') or _humanize_slug(slug)}") + + # Current assignment roles (ref_checker/publisher hidden). Blocking reason + # names are appended to the "blocked" role. + blocking_names = sorted( + _rpc_blocking_reason_label(br["reason"]["name"]) + for br in (obj.get("blocking_reasons") or []) + if br.get("reason", {}).get("name") + ) + for role in sorted(roles - RPC_QUEUE_HIDDEN_ROLES): + label = _rpc_role_label(role) + if role == "blocked" and blocking_names: + label += ": " + ", ".join(blocking_names) + parts.append(label) + + if not parts: + return "Awaiting Editor Assignment" + return ", ".join(parts) + + @shared_task def process_rpc_queue_task(data: list): in_progress_state = State.objects.get( @@ -366,16 +449,7 @@ def process_rpc_queue_task(data: list): e.save() events.append(e) - roles = sorted(a["role"] for a in obj.get("assignment_set", [])) - next_assignments = ", ".join(roles) - blocking_names = sorted( - br["reason"]["name"] for br in obj.get("blocking_reasons", []) - ) - if blocking_names: - next_assignments += ": " + ", ".join(blocking_names) - - if next_assignments == "": - next_assignments = "Awaiting Editor Assignment" + next_assignments = format_rpc_queue_status(obj) prev_assignments_event = d.latest_event( RpcAssignmentDocEvent, type="changed_rpc_assignments" diff --git a/ietf/sync/tests_tasks.py b/ietf/sync/tests_tasks.py index 57284b72980..264a5f46bb3 100644 --- a/ietf/sync/tests_tasks.py +++ b/ietf/sync/tests_tasks.py @@ -182,7 +182,9 @@ def test_creates_assignment_event_on_first_update(self): RpcAssignmentDocEvent, type="changed_rpc_assignments" ) self.assertIsNotNone(event) - self.assertEqual(event.assignments, "first_editor, second_editor") + self.assertEqual( + event.assignments, "In Progress (First Edit), In Progress (Second Edit)" + ) def test_no_assignment_event_when_unchanged(self): """No new RpcAssignmentDocEvent when assignments match the last recorded ones.""" @@ -192,8 +194,8 @@ def test_no_assignment_event_when_unchanged(self): rev=draft.rev, by=self.system, type="changed_rpc_assignments", - assignments="first_editor", - desc="RPC status changed to first_editor", + assignments="In Progress (First Edit)", + desc="RPC status changed to In Progress (First Edit)", ) events_before = RpcAssignmentDocEvent.objects.filter(doc=draft).count() @@ -211,8 +213,8 @@ def test_assignment_desc_includes_previous_assignments(self): rev=draft.rev, by=self.system, type="changed_rpc_assignments", - assignments="first_editor", - desc="RPC status changed to first_editor", + assignments="In Progress (First Edit)", + desc="RPC status changed to In Progress (First Edit)", ) tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["second_editor"])]) @@ -220,7 +222,7 @@ def test_assignment_desc_includes_previous_assignments(self): event = draft.latest_event( RpcAssignmentDocEvent, type="changed_rpc_assignments" ) - self.assertIn("from first_editor", event.desc) + self.assertIn("from In Progress (First Edit)", event.desc) def test_blocking_reasons_appended_to_assignments(self): """Blocking reason names are appended after ':' in the assignment string, sorted.""" @@ -257,7 +259,9 @@ def test_roles_sorted_in_assignment_string(self): event = draft.latest_event( RpcAssignmentDocEvent, type="changed_rpc_assignments" ) - self.assertEqual(event.assignments, "first_editor, second_editor") + self.assertEqual( + event.assignments, "In Progress (First Edit), In Progress (Second Edit)" + ) def test_empty_roles_uses_awaiting_editor_assignment(self): """Empty assignment_set records 'Awaiting Editor Assignment' rather than an empty string.""" @@ -384,8 +388,8 @@ def test_auth48_url_created_when_assignments_unchanged(self): rev=draft.rev, by=self.system, type="changed_rpc_assignments", - assignments="first_editor", - desc="RPC status changed to first_editor", + assignments="In Progress (First Edit)", + desc="RPC status changed to In Progress (First Edit)", ) tasks.process_rpc_queue_task( @@ -420,8 +424,8 @@ def test_auth48_url_deleted_when_assignments_unchanged(self): rev=draft.rev, by=self.system, type="changed_rpc_assignments", - assignments="first_editor", - desc="RPC status changed to first_editor", + assignments="In Progress (First Edit)", + desc="RPC status changed to In Progress (First Edit)", ) tasks.process_rpc_queue_task([_make_entry(draft.name, roles=["first_editor"])]) @@ -477,6 +481,151 @@ def test_docs_in_queue_retain_rfceditor_state(self): self.assertIsNotNone(draft.get_state("draft-rfceditor")) +class FormatRpcQueueStatusTests(TestCase): + """Unit tests for the queue "Status" renderer, mirroring the ietf-tools/queue site.""" + + def test_editor_roles_get_friendly_labels(self): + self.assertEqual( + tasks.format_rpc_queue_status( + {"assignment_set": [{"role": "first_editor"}]} + ), + "In Progress (First Edit)", + ) + self.assertEqual( + tasks.format_rpc_queue_status( + {"assignment_set": [{"role": "final_review_editor"}]} + ), + "In Final Review", + ) + + def test_roles_sorted_and_joined(self): + self.assertEqual( + tasks.format_rpc_queue_status( + {"assignment_set": [{"role": "second_editor"}, {"role": "first_editor"}]} + ), + "In Progress (First Edit), In Progress (Second Edit)", + ) + + def test_ref_checker_and_publisher_hidden(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [ + {"role": "ref_checker"}, + {"role": "publisher"}, + {"role": "first_editor"}, + ] + } + ), + "In Progress (First Edit)", + ) + + def test_pending_activities_awaiting(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [], + "pending_activities": [{"slug": "first_editor", "name": "First editor"}], + } + ), + "Awaiting First editor", + ) + + def test_pending_ref_checker_and_publisher_are_shown(self): + # ref_checker/publisher are hidden only as current-role badges, not as + # pending activities (matches the queue site). + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [], + "pending_activities": [ + {"slug": "ref_checker", "name": "Reference Checker"}, + {"slug": "publisher", "name": "Publisher"}, + ], + } + ), + "Awaiting Publisher, Awaiting Reference Checker", + ) + + def test_pending_activity_skipped_when_already_assigned(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [{"role": "first_editor"}], + "pending_activities": [{"slug": "first_editor", "name": "First editor"}], + } + ), + "In Progress (First Edit)", + ) + + def test_iana_hold(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [{"role": "first_editor"}], + "iana_status": {"slug": "not_completed"}, + } + ), + "IANA hold, In Progress (First Edit)", + ) + + def test_iana_hold_only_when_first_editor_present(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [{"role": "second_editor"}], + "iana_status": {"slug": "not_completed"}, + } + ), + "In Progress (Second Edit)", + ) + + def test_blocked_with_reasons_sorted(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [{"role": "blocked"}], + "blocking_reasons": [ + {"reason": {"name": "Stream Hold"}}, + {"reason": {"name": "Manual Hold"}}, + ], + } + ), + "blocked: Manual Hold, Stream Hold", + ) + + def test_blocking_reason_special_case(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [{"role": "blocked"}], + "blocking_reasons": [ + {"reason": {"name": "Reference: First Edit Incomplete"}} + ], + } + ), + "blocked: Author Input Required", + ) + + def test_blocked_suppresses_pending_activities(self): + self.assertEqual( + tasks.format_rpc_queue_status( + { + "assignment_set": [{"role": "blocked"}], + "pending_activities": [{"slug": "first_editor", "name": "First editor"}], + "blocking_reasons": [], + } + ), + "blocked", + ) + + def test_empty_is_awaiting_editor_assignment(self): + self.assertEqual( + tasks.format_rpc_queue_status({"assignment_set": []}), + "Awaiting Editor Assignment", + ) + + class UpdateErrataFromRfcEditorTaskTests(TestCase): @mock.patch("ietf.sync.tasks.update_rfc_json_task.delay") @mock.patch("ietf.sync.tasks.update_errata_from_rfceditor") diff --git a/ietf/templates/doc/document_draft.html b/ietf/templates/doc/document_draft.html index 57194144989..a39fdf6eb73 100644 --- a/ietf/templates/doc/document_draft.html +++ b/ietf/templates/doc/document_draft.html @@ -583,13 +583,13 @@ - RFC Editor state + RFC Editor status - {{ rfc_editor_state }} + {{ rfc_editor_queue_status|default:rfc_editor_state }} diff --git a/ietf/templates/doc/state_help.html b/ietf/templates/doc/state_help.html index 606e13cbacf..3a61e048996 100644 --- a/ietf/templates/doc/state_help.html +++ b/ietf/templates/doc/state_help.html @@ -14,6 +14,37 @@

{{ title }}

href="{% static 'ietf/images/iesg-draft-state-diagram.png' %}">View diagram

{% endif %} + {% if queue_status_values %} +

Queue status

+

+ The RFC Editor state shown for a document in the publication queue is its + Status in the RFC Production Center's publication queue, matching + what is shown at + the RFC Editor publication queue. + This status is derived from the document's current editor assignments, pending + activities, blocking reasons and IANA status, so it can take the following forms. + (Processing labels such as github, markdown and + Expedited shown on the publication queue are not part of this status.) +

+ + + + + + + + + {% for value, description in queue_status_values %} + + + + + {% endfor %} + +
StatusDescription
{{ value }}{{ description }}
+

Legacy states

+

{{ legacy_states_note }}

+ {% endif %}