Skip to content

Commit

Permalink
feat: enable editorial stream adoption and balloting (#5011)
Browse files Browse the repository at this point in the history
* feat: enable editorial stream adoption and balloting

* fix: bring tests into line with refactor

* feat: force intended_std_level to Informational when adopting into a non-ietf stream.

* fix: improve blocking position labels and email content

* fix: simplify pointer to group on doc main page for rswg docs

* fix: recover from merge typos

* fix: correct defer and clear ballot behavior

* fix: improve publication request access logic

* fix: clean up broken editorial state

* fix: adjust test to match migrations
  • Loading branch information
rjsparks authored Jan 31, 2023
1 parent 7e2b062 commit afac1f8
Show file tree
Hide file tree
Showing 37 changed files with 2,079 additions and 288 deletions.
47 changes: 47 additions & 0 deletions ietf/doc/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,3 +531,50 @@ class DocExtResourceFactory(factory.django.DjangoModelFactory):
class Meta:
model = DocExtResource

class EditorialDraftFactory(BaseDocumentFactory):

type_id = 'draft'
group = factory.SubFactory('ietf.group.factories.GroupFactory',acronym='rswg', type_id='rfcedtyp')
stream_id = 'editorial'

@factory.post_generation
def states(obj, create, extracted, **kwargs):
if not create:
return
if extracted:
for (state_type_id,state_slug) in extracted:
obj.set_state(State.objects.get(type_id=state_type_id,slug=state_slug))
if not obj.get_state('draft-iesg'):
obj.set_state(State.objects.get(type_id='draft-iesg',slug='idexists'))
else:
obj.set_state(State.objects.get(type_id='draft',slug='active'))
obj.set_state(State.objects.get(type_id='draft-stream-editorial',slug='active'))
obj.set_state(State.objects.get(type_id='draft-iesg',slug='idexists'))

class EditorialRfcFactory(RgDraftFactory):

alias2 = factory.RelatedFactory('ietf.doc.factories.DocAliasFactory','document',name=factory.Sequence(lambda n: 'rfc%04d'%(n+1000)))

std_level_id = 'inf'

@factory.post_generation
def states(obj, create, extracted, **kwargs):
if not create:
return
if extracted:
for (state_type_id,state_slug) in extracted:
obj.set_state(State.objects.get(type_id=state_type_id,slug=state_slug))
if not obj.get_state('draft-stream-editorial'):
obj.set_state(State.objects.get(type_id='draft-stream-editorial', slug='pub'))
if not obj.get_state('draft-iesg'):
obj.set_state(State.objects.get(type_id='draft-iesg',slug='idexists'))
else:
obj.set_state(State.objects.get(type_id='draft',slug='rfc'))
obj.set_state(State.objects.get(type_id='draft-stream-editorial', slug='pub'))
obj.set_state(State.objects.get(type_id='draft-iesg',slug='idexists'))

@factory.post_generation
def reset_canonical_name(obj, create, extracted, **kwargs):
if hasattr(obj, '_canonical_name'):
del obj._canonical_name
return None
51 changes: 51 additions & 0 deletions ietf/doc/mails.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,9 @@ def generate_publication_request(request, doc):
if doc.stream_id == "irtf":
approving_body = "IRSG"
consensus_body = doc.group.acronym.upper()
if doc.stream_id == "editorial":
approving_body = "RSAB"
consensus_body = doc.group.acronym.upper()
else:
approving_body = str(doc.stream)
consensus_body = approving_body
Expand Down Expand Up @@ -486,6 +489,54 @@ def email_irsg_ballot_closed(request, doc, ballot):
"doc/mail/close_irsg_ballot_mail.txt",
)

def _send_rsab_ballot_email(request, doc, ballot, subject, template):
"""Send email notification when IRSG ballot is issued"""
(to, cc) = gather_address_lists('rsab_ballot_issued', doc=doc)
sender = 'IESG Secretary <[email protected]>'

active_ballot = doc.active_ballot()
if active_ballot is None:
needed_bps = ''
else:
needed_bps = needed_ballot_positions(
doc,
list(active_ballot.active_balloter_positions().values())
)

return send_mail(
request=request,
frm=sender,
to=to,
cc=cc,
subject=subject,
extra={'Reply-To': [sender]},
template=template,
context=dict(
doc=doc,
doc_url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url(),
needed_ballot_positions=needed_bps,
))

def email_rsab_ballot_issued(request, doc, ballot):
"""Send email notification when RSAB ballot is issued"""
return _send_rsab_ballot_email(
request,
doc,
ballot,
'RSAB ballot issued: %s to %s'%(doc.file_tag(), std_level_prompt(doc)),
'doc/mail/issue_rsab_ballot_mail.txt',
)

def email_rsab_ballot_closed(request, doc, ballot):
"""Send email notification when RSAB ballot is closed"""
return _send_rsab_ballot_email(
request,
doc,
ballot,
'RSAB ballot closed: %s to %s'%(doc.file_tag(), std_level_prompt(doc)),
"doc/mail/close_rsab_ballot_mail.txt",
)

def email_iana(request, doc, to, msg, cc=None):
# fix up message and send it with extra info on doc in headers
import email
Expand Down
51 changes: 51 additions & 0 deletions ietf/doc/migrations/0049_add_rsab_doc_positions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Copyright The IETF Trust 2022, All Rights Reserved
# -*- coding: utf-8 -*-

from django.db import migrations


def forward(apps, schema_editor):
State = apps.get_model("doc", "State")
State.objects.create(
type_id="draft-stream-editorial",
slug="rsab_review",
name="RSAB Review",
desc="RSAB Review",
used=True,
)
BallotPositionName = apps.get_model("name", "BallotPositionName")
BallotPositionName.objects.create(slug="concern", name="Concern", blocking=True)

BallotType = apps.get_model("doc", "BallotType")
bt = BallotType.objects.create(
doc_type_id="draft",
slug="rsab-approve",
name="RSAB Approve",
question="Is this draft ready for publication in the Editorial stream?",
)
bt.positions.set(
["yes", "concern", "recuse"]
) # See RFC9280 section 3.2.2 list item 9.


def reverse(apps, schema_editor):
State = apps.get_model("doc", "State")
State.objects.filter(type_id="draft-stream-editorial", slug="rsab_review").delete()

Position = apps.get_model("name", "BallotPositionName")
Position.objects.filter(slug="concern").delete()

BallotType = apps.get_model("doc", "BallotType")
BallotType.objects.filter(slug="irsg-approve").delete()


class Migration(migrations.Migration):

dependencies = [
("doc", "0048_allow_longer_notify"),
("name", "0045_polls_and_chatlogs"),
]

operations = [
migrations.RunPython(forward, reverse),
]
43 changes: 43 additions & 0 deletions ietf/doc/migrations/0050_editorial_stream_states.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Copyright The IETF Trust 2022, All Rights Reserved
from django.db import migrations


def forward(apps, schema_editor):
State = apps.get_model("doc", "State")
StateType = apps.get_model("doc", "StateType")
StateType.objects.create(
slug="draft-stream-editorial", label="Editorial stream state"
)
for slug, name, order in (
("repl", "Replaced editorial stream document", 0),
("active", "Active editorial stream document", 2),
("rsabpoll", "Editorial stream document under RSAB review", 3),
("pub", "Published RFC", 4),
("dead", "Dead editorial stream document", 5),
):
State.objects.create(
type_id="draft-stream-editorial",
slug=slug,
name=name,
order=order,
used=True,
)
State.objects.filter(type_id="draft-stream-editorial", slug="rsab_review").delete()



def reverse(apps, schema_editor):
State = apps.get_model("doc", "State")
StateType = apps.get_model("doc", "StateType")
State.objects.filter(type_id="draft-stream-editorial").delete()
StateType.objects.filter(slug="draft-stream-editorial").delete()
# Intentionally not trying to return broken rsab_review State object


class Migration(migrations.Migration):

dependencies = [
("doc", "0049_add_rsab_doc_positions"),
]

operations = [migrations.RunPython(forward, reverse)]
6 changes: 3 additions & 3 deletions ietf/doc/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ class DocumentInfo(models.Model):

states = models.ManyToManyField(State, blank=True) # plain state (Active/Expired/...), IESG state, stream state
tags = models.ManyToManyField(DocTagName, blank=True) # Revised ID Needed, ExternalParty, AD Followup, ...
stream = ForeignKey(StreamName, blank=True, null=True) # IETF, IAB, IRTF, Independent Submission
stream = ForeignKey(StreamName, blank=True, null=True) # IETF, IAB, IRTF, Independent Submission, Editorial
group = ForeignKey(Group, blank=True, null=True) # WG, RG, IAB, IESG, Edu, Tools

abstract = models.TextField(blank=True)
Expand Down Expand Up @@ -1341,7 +1341,7 @@ class BallotDocEvent(DocEvent):
ballot_type = ForeignKey(BallotType)

def active_balloter_positions(self):
"""Return dict mapping each active AD or IRSG member to a current ballot position (or None if they haven't voted)."""
"""Return dict mapping each active member of the balloting body to a current ballot position (or None if they haven't voted)."""
res = {}

active_balloters = get_active_balloters(self.ballot_type)
Expand Down Expand Up @@ -1384,7 +1384,7 @@ def all_positions(self):
while p.old_positions and p.old_positions[-1].slug == "norecord":
p.old_positions.pop()

# add any missing ADs/IRSGers through fake No Record events
# add any missing balloters through fake No Record events
if self.doc.active_ballot() == self:
norecord = BallotPositionName.objects.get(slug="norecord")
for balloter in active_balloters:
Expand Down
8 changes: 6 additions & 2 deletions ietf/doc/templatetags/ballot_icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ def showballoticon(doc):
if doc.type_id == "draft":
if doc.stream_id == 'ietf' and doc.get_state_slug("draft-iesg") not in IESG_BALLOT_ACTIVE_STATES:
return False
elif doc.stream_id == 'irtf' and doc.get_state_slug("draft-stream-irtf") not in ['irsgpoll']:
elif doc.stream_id == 'irtf' and doc.get_state_slug("draft-stream-irtf") != "irsgpoll":
return False
elif doc.stream_id == 'editorial' and doc.get_state_slug("draft-stream-rsab") != "rsabpoll":
return False
elif doc.type_id == "charter":
if doc.get_state_slug() not in ("intrev", "extrev", "iesgrev"):
Expand Down Expand Up @@ -105,8 +107,10 @@ def sort_key(t):
break

typename = "Unknown"
if ballot.ballot_type.slug=='irsg-approve':
if ballot.ballot_type.slug == "irsg-approve":
typename = "IRSG"
elif ballot.ballot_type.slug == "rsab-approve":
typename = "RSAB"
else:
typename = "IESG"

Expand Down
22 changes: 17 additions & 5 deletions ietf/doc/templatetags/ietf_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

from ietf.doc.models import BallotDocEvent, DocAlias
from ietf.doc.models import ConsensusDocEvent
from ietf.ietfauth.utils import can_request_rfc_publication as utils_can_request_rfc_publication
from ietf.utils.html import sanitize_fragment
from ietf.utils import log
from ietf.doc.utils import prettify_std_name
Expand Down Expand Up @@ -577,6 +578,8 @@ def pos_to_label_format(text):
'Recuse': 'bg-recuse text-light',
'Not Ready': 'bg-discuss text-light',
'Need More Time': 'bg-discuss text-light',
'Concern': 'bg-discuss text-light',

}.get(str(text), 'bg-norecord text-dark')

@register.filter
Expand All @@ -591,6 +594,7 @@ def pos_to_border_format(text):
'Recuse': 'border-recuse',
'Not Ready': 'border-discuss',
'Need More Time': 'border-discuss',
'Concern': 'border-discuss',
}.get(str(text), 'border-norecord')

@register.filter
Expand Down Expand Up @@ -664,17 +668,25 @@ def charter_minor_rev(rev):
@register.filter()
def can_defer(user,doc):
ballot = doc.latest_event(BallotDocEvent, type="created_ballot")
if ballot and (doc.type_id == "draft" or doc.type_id == "conflrev") and doc.stream_id == 'ietf' and has_role(user, 'Area Director,Secretariat'):
if ballot and (doc.type_id == "draft" or doc.type_id == "conflrev" or doc.type_id=="statchg") and doc.stream_id == 'ietf' and has_role(user, 'Area Director,Secretariat'):
return True
else:
return False

@register.filter()
def can_clear_ballot(user, doc):
return can_defer(user, doc)

@register.filter()
def can_request_rfc_publication(user, doc):
return utils_can_request_rfc_publication(user, doc)

@register.filter()
def can_ballot(user,doc):
# Only IRSG members (and the secretariat, handled by code separately) can take positions on IRTF documents
# Otherwise, an AD can take a position on anything that has a ballot open
if doc.type_id == 'draft' and doc.stream_id == 'irtf':
return has_role(user,'IRSG Member')
if doc.stream_id == "irtf" and doc.type_id == "draft":
return has_role(user,"IRSG Member")
elif doc.stream_id == "editorial" and doc.type_id == "draft":
return has_role(user,"RSAB Member")
else:
return user.person.role_set.filter(name="ad", group__type="area", group__state="active")

Expand Down
32 changes: 31 additions & 1 deletion ietf/doc/tests_ballot.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
from ietf.doc.models import (Document, State, DocEvent,
BallotPositionDocEvent, LastCallDocEvent, WriteupDocEvent, TelechatDocEvent)
from ietf.doc.factories import (DocumentFactory, IndividualDraftFactory, IndividualRfcFactory, WgDraftFactory,
BallotPositionDocEventFactory, BallotDocEventFactory)
BallotPositionDocEventFactory, BallotDocEventFactory, IRSGBallotDocEventFactory)
from ietf.doc.templatetags.ietf_filters import can_defer
from ietf.doc.utils import create_ballot_if_not_open
from ietf.doc.views_doc import document_ballot_content
from ietf.group.models import Group, Role
Expand Down Expand Up @@ -1069,6 +1070,35 @@ def setUp(self):
DocumentFactory(type_id='statchg',name='status-change-imaginary-mid-review',states=[('statchg','iesgeval')])
DocumentFactory(type_id='conflrev',name='conflict-review-imaginary-irtf-submission',states=[('conflrev','iesgeval')])

class IetfFiltersTests(TestCase):
def test_can_defer(self):
secretariat = Person.objects.get(user__username="secretary").user
ad = Person.objects.get(user__username="ad").user
irtf_chair = Person.objects.get(user__username="irtf-chair").user
rsab_chair = Person.objects.get(user__username="rsab-chair").user
irsg_member = RoleFactory(group__type_id="rg", name_id="chair").person.user
rsab_member = RoleFactory(group=Group.objects.get(acronym="rsab"), name_id="member").person.user
nobody = PersonFactory().user

users = set([secretariat, ad, irtf_chair, rsab_chair, irsg_member, rsab_member, nobody])

iesg_ballot = BallotDocEventFactory(doc__stream_id='ietf')
self.assertTrue(can_defer(secretariat, iesg_ballot.doc))
self.assertTrue(can_defer(ad, iesg_ballot.doc))
for user in users - set([secretariat, ad]):
self.assertFalse(can_defer(user, iesg_ballot.doc))

irsg_ballot = IRSGBallotDocEventFactory(doc__stream_id='irtf')
for user in users:
self.assertFalse(can_defer(user, irsg_ballot.doc))

rsab_ballot = BallotDocEventFactory(ballot_type__slug='rsab-approve', doc__stream_id='editorial')
for user in users:
self.assertFalse(can_defer(user, rsab_ballot.doc))

def test_can_clear_ballot(self):
pass # Right now, can_clear_ballot is implemented by can_defer

class RegenerateLastCallTestCase(TestCase):

def test_regenerate_last_call(self):
Expand Down
Loading

0 comments on commit afac1f8

Please sign in to comment.