Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enable editorial stream adoption and balloting #5011

Merged
merged 12 commits into from
Jan 31, 2023
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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the linter no longer complain about having the first parameter as obj instead of self?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've seen no complaints, and functionally, self makes less sense than obj given that this is a factory, and this is called by the factory engine at untypical times.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok - some places have pylint directives on these lines, but I guess they're obsolete

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),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No order=1 state? I guess it's a side effect of creating then deleting the rsab_review state and doesn't matter aside from the order.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

order is almost a nuisance field. I think we'll have less confusion with this migration as is. We can make order look prettier (to no good effect otherwise) later.

("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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see this used anywhere - is it likely to be needed (or perhaps a sign of something omitted)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's an artifact of discovering that the view is adding buttons to what's rendered late in the game. I think we should leave this, though, as those views/templates really should get refactored to put the display logic back in the template.

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