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
22 changes: 21 additions & 1 deletion ietf/doc/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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', ]
Expand Down
14 changes: 13 additions & 1 deletion ietf/doc/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions ietf/doc/migrations/0038_rpcactionholderopenentry.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
],
),
]
71 changes: 71 additions & 0 deletions ietf/doc/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 26 additions & 1 deletion ietf/doc/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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())
117 changes: 117 additions & 0 deletions ietf/doc/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))

Expand Down
Loading
Loading