diff --git a/ietf/doc/admin.py b/ietf/doc/admin.py index 86f5ac5fda1..78e55a0add7 100644 --- a/ietf/doc/admin.py +++ b/ietf/doc/admin.py @@ -15,7 +15,7 @@ AddedMessageEvent, SubmissionDocEvent, DeletedEvent, EditedAuthorsDocEvent, DocumentURL, ReviewAssignmentDocEvent, IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource, DocumentActionHolder, BofreqEditorDocEvent, BofreqResponsibleDocEvent, StoredObject, RfcAuthor, - EditedRfcAuthorsDocEvent, RpcAssignmentDocEvent) + EditedRfcAuthorsDocEvent, RpcAssignmentDocEvent, RpcActionHolderOpenEntry) from ietf.utils.admin import SaferTabularInline from ietf.utils.validators import validate_external_resource_value @@ -233,6 +233,26 @@ class RpcAssignmentDocEventAdmin(DocEventAdmin): search_fields = DocEventAdmin.search_fields + ["assignments"] admin.site.register(RpcAssignmentDocEvent, RpcAssignmentDocEventAdmin) +class RpcActionHolderOpenEntryAdmin(admin.ModelAdmin): + """Read-only view of the open action holder entries from the RPC tool + + The RPC tool owns these - every queue push replaces them - so editing them + here would accomplish nothing. + """ + list_display = ['id', 'purple_id', 'document', 'name', 'since_when', 'deadline', ] + search_fields = ['document__name', 'person__name', 'body', ] + raw_id_fields = ['document', 'person', ] + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False +admin.site.register(RpcActionHolderOpenEntry, RpcActionHolderOpenEntryAdmin) + class DocumentUrlAdmin(admin.ModelAdmin): list_display = ['id', 'doc', 'tag', 'url', 'desc', ] search_fields = ['doc__name', 'url', ] diff --git a/ietf/doc/factories.py b/ietf/doc/factories.py index 0cf83e4525f..aadac27c6f9 100644 --- a/ietf/doc/factories.py +++ b/ietf/doc/factories.py @@ -14,7 +14,8 @@ from ietf.doc.models import ( Document, DocEvent, NewRevisionDocEvent, State, DocumentAuthor, StateDocEvent, BallotPositionDocEvent, BallotDocEvent, BallotType, IRSGBallotDocEvent, TelechatDocEvent, - DocumentActionHolder, BofreqEditorDocEvent, BofreqResponsibleDocEvent, DocExtResource, RfcAuthor ) + DocumentActionHolder, BofreqEditorDocEvent, BofreqResponsibleDocEvent, DocExtResource, RfcAuthor, + RpcActionHolderOpenEntry ) from ietf.group.models import Group from ietf.person.factories import PersonFactory from ietf.group.factories import RoleFactory @@ -384,6 +385,17 @@ class Meta: document = factory.SubFactory(WgDraftFactory) person = factory.SubFactory('ietf.person.factories.PersonFactory') +class RpcActionHolderOpenEntryFactory(factory.django.DjangoModelFactory): + class Meta: + model = RpcActionHolderOpenEntry + + purple_id = factory.Sequence(lambda n: n + 1) + document = factory.SubFactory(WgDraftFactory) + person = factory.SubFactory('ietf.person.factories.PersonFactory') + display_name = factory.LazyAttribute(lambda o: o.person.plain_name() if o.person else '') + comment = factory.Faker('sentence') + since_when = factory.LazyFunction(timezone.now) + class DocumentAuthorFactory(factory.django.DjangoModelFactory): class Meta: model = DocumentAuthor diff --git a/ietf/doc/migrations/0038_rpcactionholderopenentry.py b/ietf/doc/migrations/0038_rpcactionholderopenentry.py new file mode 100644 index 00000000000..365a7f5ba63 --- /dev/null +++ b/ietf/doc/migrations/0038_rpcactionholderopenentry.py @@ -0,0 +1,69 @@ +# Copyright The IETF Trust 2026, All Rights Reserved + +from django.db import migrations, models +import django.db.models.deletion +import ietf.utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("person", "0005_alter_historicalperson_pronouns_selectable_and_more"), + ("doc", "0037_rpcassignmentdocevent"), + ] + + operations = [ + migrations.CreateModel( + name="RpcActionHolderOpenEntry", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "purple_id", + models.PositiveIntegerField( + help_text="ID of the ActionHolder in the RPC tool", unique=True + ), + ), + ( + "body", + models.CharField( + blank=True, + default="", + help_text="Name of the body holding the action, if it is not a person", + max_length=64, + ), + ), + ( + "display_name", + models.CharField(blank=True, default="", max_length=255), + ), + ("comment", models.TextField(blank=True)), + ("rfc_number", models.PositiveIntegerField(blank=True, null=True)), + ("since_when", models.DateTimeField()), + ("deadline", models.DateTimeField(blank=True, null=True)), + ("time_captured", models.DateTimeField(auto_now=True)), + ( + "document", + ietf.utils.models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="doc.document" + ), + ), + ( + "person", + ietf.utils.models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="person.person", + ), + ), + ], + ), + ] diff --git a/ietf/doc/models.py b/ietf/doc/models.py index 3c9b80ca83a..8aed1726103 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -1655,6 +1655,77 @@ class ConsensusDocEvent(DocEvent): class RpcAssignmentDocEvent(DocEvent): assignments = models.TextField(blank=True) + +class RpcActionHolderOpenEntry(models.Model): + """Open action holder entry from the RFC Production Center + + A read-only capture of the RPC tool's open ActionHolder entries, held here + so the datatracker can query them efficiently - who is the RPC waiting on, + and for which documents. The RPC tool owns the entries. It sends the full + set for every document in the publication queue on each push to + /api/purple/queue/process/, and ietf.sync.tasks.process_rpc_queue_task + reconciles this table against that push. Nothing else writes it, there is no + editing UI, and any local change is discarded by the next push. + + Only open entries are kept: an entry the RPC has completed is dropped at + ingest, and rows disappear when their document leaves the publication queue. + Every row present is therefore one the RPC is still waiting on, so readers + do not need to filter. + + Not to be confused with DocumentActionHolder, which is the datatracker's own + action holder list for documents in IESG processing. + """ + + purple_id = models.PositiveIntegerField( + unique=True, help_text="ID of the ActionHolder in the RPC tool" + ) + document = ForeignKey(Document) + # Null when a body rather than a person holds the action, when the RPC tool + # sent its system person as a placeholder, or when it named a person the + # datatracker cannot resolve. Never the "(System)" person. + person = ForeignKey(Person, blank=True, null=True) + body = models.CharField( + max_length=64, + blank=True, + default="", + help_text="Name of the body holding the action, if it is not a person", + ) + display_name = models.CharField(max_length=255, blank=True, default="") + comment = models.TextField(blank=True) + # Belongs to the document rather than to the action holder, and is null + # until the RPC assigns it. Used to build the final review link. + rfc_number = models.PositiveIntegerField(blank=True, null=True) + # Only the date components of since_when / deadline are significant - the + # RPC tool customarily sets the time to 12:00 UTC. + since_when = models.DateTimeField() + deadline = models.DateTimeField(blank=True, null=True) + time_captured = models.DateTimeField(auto_now=True) + + def __str__(self): + return "%s: open action held by %s" % (self.document.name, self.name()) + + def name(self): + """Best available label for whoever holds this action""" + if self.body: + return self.body + if self.person: + return self.person.plain_name() + return self.display_name + + def final_review_url(self): + """Link to the queue site final review page, if there is one + + A document can have an action holder before the RPC assigns it an RFC + number, and there is no final review page until it does. + """ + if self.rfc_number is None: + return None + return "%s/final-review/rfc%d/" % ( + settings.RFC_EDITOR_QUEUE_SITE_BASE_URL, + self.rfc_number, + ) + + # IESG events class BallotType(models.Model): doc_type = ForeignKey(DocTypeName, blank=True, null=True) diff --git a/ietf/doc/resources.py b/ietf/doc/resources.py index 9da7cb57d80..f7db33925cd 100644 --- a/ietf/doc/resources.py +++ b/ietf/doc/resources.py @@ -19,7 +19,7 @@ ReviewRequestDocEvent, ReviewAssignmentDocEvent, EditedAuthorsDocEvent, DocumentURL, IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource, DocumentActionHolder, BofreqEditorDocEvent, BofreqResponsibleDocEvent, StoredObject, RfcAuthor, - EditedRfcAuthorsDocEvent, RpcAssignmentDocEvent) + EditedRfcAuthorsDocEvent, RpcAssignmentDocEvent, RpcActionHolderOpenEntry) from ietf.name.resources import BallotPositionNameResource, DocTypeNameResource class BallotTypeResource(ModelResource): @@ -941,3 +941,28 @@ class Meta: "docevent_ptr": ALL_WITH_RELATIONS, } api.doc.register(RpcAssignmentDocEventResource()) + + +class RpcActionHolderOpenEntryResource(ModelResource): + document = ToOneField(DocumentResource, 'document') + person = ToOneField(PersonResource, 'person', null=True) + class Meta: + queryset = RpcActionHolderOpenEntry.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'rpcactionholderopenentry' + ordering = ['id', ] + filtering = { + "id": ALL, + "purple_id": ALL, + "body": ALL, + "display_name": ALL, + "comment": ALL, + "rfc_number": ALL, + "since_when": ALL, + "deadline": ALL, + "time_captured": ALL, + "document": ALL_WITH_RELATIONS, + "person": ALL_WITH_RELATIONS, + } +api.doc.register(RpcActionHolderOpenEntryResource()) diff --git a/ietf/doc/tests.py b/ietf/doc/tests.py index 2e9494d0d87..1947fe272a4 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -56,11 +56,13 @@ StatusChangeFactory, DocExtResourceFactory, RgDraftFactory, BcpFactory, StdFactory, FyiFactory, RfcAuthorFactory, + RpcActionHolderOpenEntryFactory, TelechatDocEventFactory) from ietf.doc.forms import NotifyForm from ietf.doc.fields import SearchableDocumentsField from ietf.doc.utils import ( create_ballot_if_not_open, + save_document_in_history, external_canonical_url, investigate_fragment, uppercase_std_abbreviated_name, @@ -724,6 +726,80 @@ def test_docs_for_ad(self): self.assertContains(r, discuss_other.doc.name) self.assertContains(r, block_other.doc.name) + def test_ad_workload_shows_rpc_decisions_pending(self): + """The IESG dashboard shows who the RPC is currently waiting on""" + # ad_list.html builds element ids from AD names, so start from a known + # set of ADs whose names slugify predictably. + Role.objects.filter(name_id="ad").delete() + ad = RoleFactory(name_id='ad', group__type_id='area', group__state_id='active', + person__name='Example Areadirector').person + idle_ad = RoleFactory(name_id='ad', group__type_id='area', group__state_id='active', + person__name='Other Areadirector').person + RpcActionHolderOpenEntryFactory(person=ad) + RpcActionHolderOpenEntryFactory(person=ad) + + url = urlreverse('ietf.doc.views_search.ad_workload') + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'RPC decisions pending') + q = PyQuery(r.content) + rows = q('#rpc-pending').next('p').next('table').find('tbody tr') + self.assertEqual(len(rows), 1, 'only ADs with something pending get a row') + self.assertIn(ad.plain_name(), rows.text()) + self.assertNotIn(idle_ad.plain_name(), rows.text()) + self.assertIn('2', rows.text()) + + def test_ad_workload_without_rpc_decisions_pending(self): + """The section is absent when the RPC is waiting on nobody""" + Role.objects.filter(name_id="ad").delete() + RoleFactory(name_id='ad', group__type_id='area', group__state_id='active', + person__name='Example Areadirector') + r = self.client.get(urlreverse('ietf.doc.views_search.ad_workload')) + self.assertEqual(r.status_code, 200) + self.assertNotContains(r, 'RPC decisions pending') + + def test_docs_for_ad_shows_rpc_action_holders(self): + """An AD sees the decisions the RPC is waiting on them for""" + ad = RoleFactory(name_id='ad', group__type_id='area', group__state_id='active').person + other_ad = RoleFactory(name_id='ad', group__type_id='area', group__state_id='active').person + entry = RpcActionHolderOpenEntryFactory( + person=ad, comment='Confirm the change in section 4.2', rfc_number=9850 + ) + other_entry = RpcActionHolderOpenEntryFactory(person=other_ad) + + url = urlreverse('ietf.doc.views_search.docs_for_ad', + kwargs=dict(name=ad.full_name_as_key())) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'RPC decisions pending') + self.assertContains(r, entry.document.name) + self.assertNotContains(r, other_entry.document.name) + # the queue site final review page for this document + self.assertContains(r, f'{settings.RFC_EDITOR_QUEUE_SITE_BASE_URL}/final-review/rfc9850/') + # the request is shown here to everyone, including the anonymous user + self.assertContains(r, 'Confirm the change in section 4.2') + + self.client.login(username=ad.user.username, password=ad.user.username + '+password') + self.assertContains(self.client.get(url), 'Confirm the change in section 4.2') + + def test_docs_for_ad_without_rpc_action_holders(self): + """The section is absent when the RPC is waiting on nothing""" + ad = RoleFactory(name_id='ad', group__type_id='area', group__state_id='active').person + r = self.client.get(urlreverse('ietf.doc.views_search.docs_for_ad', + kwargs=dict(name=ad.full_name_as_key()))) + self.assertEqual(r.status_code, 200) + self.assertNotContains(r, 'RPC decisions pending') + + def test_docs_for_ad_rpc_action_holder_without_rfc_number(self): + """A document with no rfc number yet has no final review page to link""" + ad = RoleFactory(name_id='ad', group__type_id='area', group__state_id='active').person + RpcActionHolderOpenEntryFactory(person=ad, rfc_number=None) + r = self.client.get(urlreverse('ietf.doc.views_search.docs_for_ad', + kwargs=dict(name=ad.full_name_as_key()))) + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'RPC decisions pending') + self.assertNotContains(r, '/final-review/') + def test_docs_for_iesg(self): ad1 = RoleFactory(name_id='ad',group__type_id='area',group__state_id='active').person ad2 = RoleFactory(name_id='ad',group__type_id='area',group__state_id='active').person @@ -1102,6 +1178,47 @@ def setUp(self): with (Path(dir) / 'draft-ietf-mars-test-01.txt').open('w') as f: f.write(self.draft_text) + def test_document_draft_rpc_action_holders(self): + """A draft in the RFC Editor queue shows who the RPC is waiting on""" + draft = WgDraftFactory(states=[('draft-iesg', 'rfcqueue'), + ('draft-rfceditor', 'in_progress')], + rev='00') + holder = PersonFactory() + RpcActionHolderOpenEntryFactory( + document=draft, person=holder, comment='Confirm the change in section 4.2' + ) + # An action held by a body is not shown here - the queue status covers it + RpcActionHolderOpenEntryFactory( + document=draft, person=None, body='Registry Of Xyzzy', + display_name='Registry Of Xyzzy' + ) + url = urlreverse('ietf.doc.views_doc.document_main', kwargs=dict(name=draft.name)) + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, escape(holder.name)) + self.assertNotContains(r, 'Registry Of Xyzzy') + self.assertNotContains(r, 'Confirm the change in section 4.2') + + # the person being asked can see the request even though they are not an AD + self.client.login(username=holder.user.username, + password=holder.user.username + '+password') + self.assertContains(self.client.get(url), 'Confirm the change in section 4.2') + + # An earlier revision is a snapshot of the past and reports none of it. + # The snapshot is taken while the draft is in the queue, so its own + # RFC Editor state is set and that block of the page does render. + save_document_in_history(draft) + draft.rev = '01' + draft.save() + snapshot_url = urlreverse('ietf.doc.views_doc.document_main', + kwargs=dict(name=draft.name, rev='00')) + r = self.client.get(snapshot_url) + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'RFC Editor status') # the block is there ... + self.assertNotContains(r, escape(holder.name)) # ... without the holders + self.assertNotContains(r, 'Confirm the change in section 4.2') + def test_document_draft(self): draft = WgDraftFactory(name='draft-ietf-mars-test',rev='01', create_revisions=range(0,2)) diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index c371ba6f2e4..a353cfa3f2b 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -456,6 +456,24 @@ def add_state_change_event(doc, by, prev_state, new_state, prev_tags=None, new_t return e +def show_rpc_action_holder_comments(user, entries): + """Should this user be shown the RPC's requests to these action holders? + + Controls whether a view renders the comment the RPC attached to an action + holder. Only the document main page views ask: they show it to the IESG, the + Secretariat and the RPC, and to whoever is being asked for the decision. + Views that do not call this show the comments to everyone, as the AD + document list does. The text is not confidential either way - the queue site + publishes it on its public final-review pages - so this is a choice about + what belongs on a given page. + """ + if has_role(user, ["Area Director", "Secretariat", "RFC Editor"]): + return True + if not (user.is_authenticated and hasattr(user, "person")): + return False + return any(entry.person_id == user.person.pk for entry in entries) + + def add_action_holder_change_event(doc, by, prev_set, reason=None): set_changed = False if doc.documentactionholder_set.exclude(person__in=prev_set).exists(): diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 4d2edbea5c1..6ae100e6cb0 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -60,9 +60,9 @@ 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, RpcActionHolderOpenEntry, RpcAssignmentDocEvent) from ietf.doc.tasks import investigate_fragment_task -from ietf.doc.utils import (augment_events_with_revision, +from ietf.doc.utils import (augment_events_with_revision, show_rpc_action_holder_comments, can_adopt_draft, can_unadopt_draft, get_chartering_type, get_tags_for_stream_id, needed_ballot_positions, nice_consensus, update_telechat, has_same_ballot, get_initial_notify, make_notify_changed_event, make_rev_history, default_consensus, @@ -198,6 +198,35 @@ 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 rpc_action_holders(doc): + """Open action holder entries the RPC has for this document + + Only entries naming a person the datatracker knows: an action held by a + body, or by the RPC tool's own placeholder person, is not something to show + here - the queue status already reports that the RPC is waiting on someone. + """ + return list( + RpcActionHolderOpenEntry.objects.filter( + document=doc, person__isnull=False + ).select_related("person").order_by("since_when") + ) + + def document_main(request, name, rev=None, document_html=False): doc = get_object_or_404(Document.objects.select_related(), name=name) @@ -650,6 +679,11 @@ def document_main(request, name, rev=None, document_html=False): if html: css += Path(finders.find("ietf/css/document_html_txt.css")).read_text() + # Actions the RPC is waiting on, while the document is in its queue. + # The entries describe the document as it stands now, so a view of an + # earlier revision has none. + doc_rpc_action_holders = [] if snapshot else rpc_action_holders(doc) + return render(request, "doc/document_draft.html" if document_html is False else "doc/document_html.html", dict(doc=doc, document_html=document_html, @@ -712,6 +746,9 @@ def document_main(request, name, rev=None, document_html=False): rfc_editor_state=doc.get_state("draft-rfceditor"), rfc_editor_queue_status=doc.rfc_editor_queue_status(), rfc_editor_auth48_url=auth48_url, + rpc_action_holders=doc_rpc_action_holders, + show_rpc_action_holder_comments=show_rpc_action_holder_comments( + request.user, doc_rpc_action_holders), 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"), diff --git a/ietf/doc/views_search.py b/ietf/doc/views_search.py index 7cf84b52768..9da1aa04529 100644 --- a/ietf/doc/views_search.py +++ b/ietf/doc/views_search.py @@ -48,7 +48,7 @@ from django.conf import settings from django.core.cache import cache, caches from django.urls import reverse as urlreverse -from django.db.models import Model, Q +from django.db.models import Count, Model, Q from django.http import Http404, HttpResponseBadRequest, HttpResponse, HttpResponseRedirect, QueryDict from django.shortcuts import render from django.utils import timezone @@ -59,11 +59,14 @@ import debug # pyflakes:ignore from ietf.doc.models import ( Document, DocHistory, DocumentAuthor, RelatedDocument, - RfcAuthor, State, NewRevisionDocEvent, IESG_SUBSTATE_TAGS, + RfcAuthor, RpcActionHolderOpenEntry, State, NewRevisionDocEvent, IESG_SUBSTATE_TAGS, IESG_BALLOT_ACTIVE_STATES, IESG_STATCHG_CONFLREV_ACTIVE_STATES, IESG_CHARTER_ACTIVE_STATES ) from ietf.doc.fields import select2_id_doc_name_json -from ietf.doc.utils import augment_events_with_revision, needed_ballot_positions +from ietf.doc.utils import ( + augment_events_with_revision, + needed_ballot_positions, +) from ietf.group.models import Group from ietf.idindex.index import active_drafts_index_by_group from ietf.name.models import DocTagName, DocTypeName, StreamName @@ -703,10 +706,28 @@ def _state_to_doc_type(state): for dt in AD_WORKLOAD } + # Current state, not a trend: the open entries the RPC pushes are a + # snapshot, so there is no history to bucket the way the tables above do. + rpc_pending_counts = dict( + RpcActionHolderOpenEntry.objects.filter(person__in=ads) + .values_list("person") + .annotate(Count("id")) + ) + rpc_pending = [ + {"ad": ad, "count": rpc_pending_counts[ad.pk]} + for ad in ads + if rpc_pending_counts.get(ad.pk) + ] + return render( request, "doc/ad_list.html", - {"metadata": metadata, "data": data, "delta": days}, + { + "metadata": metadata, + "data": data, + "delta": days, + "rpc_pending": rpc_pending, + }, ) @@ -831,6 +852,13 @@ def sort_key(doc): if re.search(r"\bNeeds\s+\d+", iesg_ballot_summary): not_balloted_docs.append(doc) + # Actions the RPC is waiting on this AD for, from the publication queue. + rpc_action_holders = list( + RpcActionHolderOpenEntry.objects.filter(person=ad) + .select_related("document") + .order_by("since_when") + ) + return render( request, "doc/drafts_for_ad.html", @@ -840,6 +868,7 @@ def sort_key(doc): "ad": ad, "blocked_docs": blocked_docs, "not_balloted_docs": not_balloted_docs, + "rpc_action_holders": rpc_action_holders, }, ) diff --git a/ietf/sync/tasks.py b/ietf/sync/tasks.py index 24d3c77b3be..3a037667e28 100644 --- a/ietf/sync/tasks.py +++ b/ietf/sync/tasks.py @@ -11,8 +11,17 @@ from django.conf import settings from django.utils import timezone - -from ietf.doc.models import DocEvent, DocTagName, Document, RelatedDocument, RpcAssignmentDocEvent, State +from django.utils.dateparse import parse_datetime + +from ietf.doc.models import ( + DocEvent, + DocTagName, + Document, + RelatedDocument, + RpcActionHolderOpenEntry, + RpcAssignmentDocEvent, + State, +) from ietf.doc.tasks import rebuild_reference_relations_task from ietf.doc.utils import add_state_change_event, new_state_change_event, update_action_holders from ietf.person.models import Person @@ -382,6 +391,71 @@ def format_rpc_queue_status(obj): return ", ".join(parts) +# The datatracker's "(System)" person. The RPC tool substitutes a placeholder +# person of its own whenever no real person was named, and sends that person's +# datatracker id - so an action holder must never be associated with this pk. +# Which pk the RPC tool uses is configurable at its end, hence the separate +# check against the "(System)" person the datatracker actually has. +SYSTEM_PERSON_ID = 1 + + +def _rpc_action_holder_person(holder, system_person): + """Resolve the datatracker Person for one action holder, if there is one + + Returns None whenever the entry does not name a person the datatracker can + act on. The RPC tool substitutes its system person when no real person was + named, and that placeholder must never become an action holder here. Do not + use "body" to make this decision - the RPC tool's edit path can set or clear + "body" without touching the person, so it is unreliable in both directions. + """ + person_id = (holder.get("person") or {}).get("person_id") + if person_id in (SYSTEM_PERSON_ID, system_person.pk): + return None + if holder.get("body"): + return None # a body holds the action, not a person + person = Person.objects.filter(pk=person_id).first() + if person is None: + log.log( + f"process_rpc_queue_task: unknown action holder person {person_id}" + ) + return None + return person + + +def _sync_rpc_action_holders(doc, holders, rfc_number, system_person): + """Reconcile the open action holder entries for one document""" + open_purple_ids = [] + for holder in holders: + if holder.get("completed") is not None: + # The RPC tool reports completed action holders as well as open + # ones. Only open ones are kept here, so this line is the only + # place the datatracker sees a completion happen. + continue + RpcActionHolderOpenEntry.objects.update_or_create( + purple_id=holder["id"], + defaults=dict( + document=doc, + person=_rpc_action_holder_person(holder, system_person), + body=holder.get("body") or "", + display_name=holder.get("display_name") or "", + comment=holder.get("comment") or "", + rfc_number=rfc_number, + since_when=parse_datetime(holder["since_when"]), + deadline=( + parse_datetime(holder["deadline"]) + if holder.get("deadline") + else None + ), + ), + ) + open_purple_ids.append(holder["id"]) + # Anything else we were holding for this document has been completed or + # removed at the RPC tool. + RpcActionHolderOpenEntry.objects.filter(document=doc).exclude( + purple_id__in=open_purple_ids + ).delete() + + @shared_task def process_rpc_queue_task(data: list): in_progress_state = State.objects.get( @@ -485,6 +559,10 @@ def process_rpc_queue_task(data: list): d.tags.remove(*iana_ref_tags) + _sync_rpc_action_holders( + d, obj.get("actionholder_set") or [], rfc_number, system + ) + if events: d.save_with_history(events) @@ -495,3 +573,4 @@ def process_rpc_queue_task(data: list): ): d.tags.remove(*iana_ref_tags) d.unset_state("draft-rfceditor") + RpcActionHolderOpenEntry.objects.filter(document=d).delete() diff --git a/ietf/sync/tests_tasks.py b/ietf/sync/tests_tasks.py index 264a5f46bb3..7f0c5aee960 100644 --- a/ietf/sync/tests_tasks.py +++ b/ietf/sync/tests_tasks.py @@ -1,5 +1,6 @@ # Copyright The IETF Trust 2026, All Rights Reserved +import datetime import mock from django.test.utils import override_settings @@ -9,9 +10,11 @@ DocTagName, Document, DocumentURL, + RpcActionHolderOpenEntry, RpcAssignmentDocEvent, State, ) +from ietf.person.factories import PersonFactory from ietf.person.models import Person from ietf.sync import tasks from ietf.utils.mail import outbox @@ -19,7 +22,12 @@ def _make_entry( - doc_name, roles=None, blocking_reasons=None, rfc_number=None, final_approval=None + doc_name, + roles=None, + blocking_reasons=None, + rfc_number=None, + final_approval=None, + action_holders=None, ): return { "name": doc_name, @@ -27,6 +35,23 @@ def _make_entry( "blocking_reasons": blocking_reasons or [], "rfc_number": rfc_number, "final_approval": final_approval or [], + "actionholder_set": action_holders or [], + } + + +def _make_action_holder( + purple_id=1, person_id=None, body="", completed=None, deadline=None, comment="" +): + """Build one actionholder_set element as the RPC tool sends it""" + return { + "id": purple_id, + "person": {"person_id": person_id, "name": "Anybody"}, + "display_name": body or "Anybody", + "deadline": deadline, + "since_when": "2026-08-11T12:00:00Z", + "completed": completed, + "comment": comment, + "body": body, } @@ -481,6 +506,136 @@ def test_docs_in_queue_retain_rfceditor_state(self): self.assertIsNotNone(draft.get_state("draft-rfceditor")) +class RpcActionHolderSyncTests(TestCase): + def setUp(self): + super().setUp() + self.draft = WgDraftFactory(states=[("draft-iesg", "rfcqueue")]) + self.person = PersonFactory() + + def _sync(self, *holders, rfc_number=None): + tasks.process_rpc_queue_task( + [ + _make_entry( + self.draft.name, + roles=["first_editor"], + rfc_number=rfc_number, + action_holders=list(holders), + ) + ] + ) + return RpcActionHolderOpenEntry.objects.filter(document=self.draft) + + def test_person_holder_is_stored(self): + """An action holder naming a person is kept, with that person.""" + entries = self._sync( + _make_action_holder( + purple_id=17, + person_id=self.person.pk, + comment="Please confirm the change in section 4.2.", + deadline="2026-09-01T12:00:00Z", + ) + ) + self.assertEqual(entries.count(), 1) + entry = entries.first() + self.assertEqual(entry.purple_id, 17) + self.assertEqual(entry.person, self.person) + self.assertEqual(entry.body, "") + self.assertEqual(entry.comment, "Please confirm the change in section 4.2.") + self.assertEqual(entry.since_when.date(), datetime.date(2026, 8, 11)) + self.assertEqual(entry.deadline.date(), datetime.date(2026, 9, 1)) + + def test_body_holder_is_stored_without_a_person(self): + """An action held by a body is kept, but belongs to nobody.""" + entries = self._sync(_make_action_holder(person_id=self.person.pk, body="IANA")) + self.assertEqual(entries.count(), 1) + self.assertIsNone(entries.first().person) + self.assertEqual(entries.first().body, "IANA") + + def test_system_person_id_is_never_an_action_holder(self): + """The RPC tool's placeholder person never becomes an action holder. + + It arrives both with and without a body set - the RPC tool's edit path + can clear the body without changing the person - so the person id alone + has to be enough to reject it. + """ + for body in ("IANA", ""): + RpcActionHolderOpenEntry.objects.all().delete() + entries = self._sync( + _make_action_holder(person_id=tasks.SYSTEM_PERSON_ID, body=body) + ) + self.assertEqual(entries.count(), 1, f"body={body!r}") + self.assertIsNone(entries.first().person, f"body={body!r}") + + def test_person_resolving_to_system_is_never_an_action_holder(self): + """A person id that resolves to (System) is rejected on its own merits.""" + system = Person.objects.get(name="(System)") + entries = self._sync(_make_action_holder(person_id=system.pk)) + self.assertEqual(entries.count(), 1) + self.assertIsNone(entries.first().person) + + def test_unknown_person_is_stored_without_a_person(self): + """An unresolvable person is logged, and the entry kept and legible.""" + entries = self._sync(_make_action_holder(person_id=99999999)) + self.assertEqual(entries.count(), 1) + self.assertIsNone(entries.first().person) + self.assertEqual(entries.first().display_name, "Anybody") + + def test_completed_holder_is_not_stored(self): + """A completed action holder is dropped rather than kept.""" + entries = self._sync( + _make_action_holder( + person_id=self.person.pk, completed="2026-08-14T12:00:00Z" + ) + ) + self.assertEqual(entries.count(), 0) + + def test_completion_removes_a_stored_entry(self): + """An entry the RPC tool completes stops being held.""" + self._sync(_make_action_holder(purple_id=17, person_id=self.person.pk)) + entries = self._sync( + _make_action_holder( + purple_id=17, + person_id=self.person.pk, + completed="2026-08-14T12:00:00Z", + ) + ) + self.assertEqual(entries.count(), 0) + + def test_entry_dropped_from_the_payload_is_removed(self): + """An entry the RPC tool deletes stops being held.""" + self._sync(_make_action_holder(purple_id=17, person_id=self.person.pk)) + self.assertEqual(self._sync().count(), 0) + + def test_entries_are_removed_when_the_document_leaves_the_queue(self): + """Nothing is held for a document the RPC tool no longer reports.""" + self._sync(_make_action_holder(person_id=self.person.pk)) + tasks.process_rpc_queue_task([]) + self.assertFalse(RpcActionHolderOpenEntry.objects.exists()) + + def test_rfc_number_comes_from_the_queue_entry(self): + """The rfc number is captured from the entry, not the action holder.""" + entries = self._sync( + _make_action_holder(person_id=self.person.pk), rfc_number=9850 + ) + self.assertEqual(entries.first().rfc_number, 9850) + + def test_rfc_number_is_null_before_one_is_assigned(self): + """A document can hold an action before it has an rfc number.""" + entries = self._sync(_make_action_holder(person_id=self.person.pk)) + self.assertIsNone(entries.first().rfc_number) + + def test_existing_entry_is_updated_in_place(self): + """A second push updates the entry it already holds.""" + self._sync(_make_action_holder(purple_id=17, person_id=self.person.pk)) + entries = self._sync( + _make_action_holder( + purple_id=17, person_id=self.person.pk, comment="Now with a comment" + ) + ) + self.assertEqual(entries.count(), 1) + self.assertEqual(entries.first().comment, "Now with a comment") + + class FormatRpcQueueStatusTests(TestCase): """Unit tests for the queue "Status" renderer, mirroring the ietf-tools/queue site.""" diff --git a/ietf/templates/doc/ad_list.html b/ietf/templates/doc/ad_list.html index cac709021ea..3db2ecd24f8 100644 --- a/ietf/templates/doc/ad_list.html +++ b/ietf/templates/doc/ad_list.html @@ -8,8 +8,8 @@ {% endblock %} {% block morecss %} table .border-bottom { border-bottom-color: var(--highcharts-neutral-color-80) !important; } - .highcharts-container .highcharts-axis-labels { - font-size: .7rem; + .highcharts-container .highcharts-axis-labels { + font-size: .7rem; fill: var(--bs-body-color) } .highcharts-container .highcharts-graph { stroke-width: 2.5; } @@ -37,6 +37,29 @@

IESG Dashboard

Documents in IESG Processing IESG view of Working Groups

+ {% if rpc_pending %} +

RPC decisions pending

+

+ Documents in the RFC Editor queue for which the RPC is currently waiting on a + decision from an Area Director. +

+ + + + + + + + + {% for row in rpc_pending %} + + + + + {% endfor %} + + + {% endif %} {% for dt in metadata %}

{{ dt.type.1 }} State Counts

@@ -45,7 +68,7 @@

{{ dt.type.1 }} State Counts

{% if dt.type.1 == "Internet-Draft" %} - {% endif %} + {% endif %} {% for state, state_name in dt.states %} + {% if rpc_action_holders %} + + + + + + + {% endif %} {% endif %} + {% if rpc_action_holders %} +

RPC decisions pending from {{ ad.name }}

+ + + + + + + + + + + + {% for entry in rpc_action_holders %} + + + + + + + + {% endfor %} + +
DocumentQueue entryWaiting sinceDeadlineRequest
{{ entry.document.displayname_with_link }} + {% if entry.final_review_url %} + Final review + {% endif %} + {{ entry.since_when|date:"Y-m-d" }} + {% if entry.deadline %} + {{ entry.deadline|date:"Y-m-d" }} + {% endif %} + {{ entry.comment }}
+ {% endif %}

Documents for {{ ad.name }}

{% include "doc/search/search_results.html" with start_table=True end_table=True %} {% endblock %}