diff --git a/ietf/doc/factories.py b/ietf/doc/factories.py index a8b7b4656b..874c467d29 100644 --- a/ietf/doc/factories.py +++ b/ietf/doc/factories.py @@ -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 diff --git a/ietf/doc/mails.py b/ietf/doc/mails.py index 54e0f47e20..b158d7b761 100644 --- a/ietf/doc/mails.py +++ b/ietf/doc/mails.py @@ -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 @@ -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 ' + + 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 diff --git a/ietf/doc/migrations/0049_add_rsab_doc_positions.py b/ietf/doc/migrations/0049_add_rsab_doc_positions.py new file mode 100644 index 0000000000..fd02a6bfb7 --- /dev/null +++ b/ietf/doc/migrations/0049_add_rsab_doc_positions.py @@ -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), + ] diff --git a/ietf/doc/migrations/0050_editorial_stream_states.py b/ietf/doc/migrations/0050_editorial_stream_states.py new file mode 100644 index 0000000000..44f3216c8c --- /dev/null +++ b/ietf/doc/migrations/0050_editorial_stream_states.py @@ -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)] diff --git a/ietf/doc/models.py b/ietf/doc/models.py index 42086721b9..5d638c7427 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -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) @@ -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) @@ -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: diff --git a/ietf/doc/templatetags/ballot_icon.py b/ietf/doc/templatetags/ballot_icon.py index ee7d5f6278..19e59c34c7 100644 --- a/ietf/doc/templatetags/ballot_icon.py +++ b/ietf/doc/templatetags/ballot_icon.py @@ -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"): @@ -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" diff --git a/ietf/doc/templatetags/ietf_filters.py b/ietf/doc/templatetags/ietf_filters.py index 1137cf636a..332e5ca152 100644 --- a/ietf/doc/templatetags/ietf_filters.py +++ b/ietf/doc/templatetags/ietf_filters.py @@ -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 @@ -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 @@ -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 @@ -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") diff --git a/ietf/doc/tests_ballot.py b/ietf/doc/tests_ballot.py index 4de4de08ba..38fe25356f 100644 --- a/ietf/doc/tests_ballot.py +++ b/ietf/doc/tests_ballot.py @@ -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 @@ -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): diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index dda6453de4..6450551528 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -11,6 +11,7 @@ from pathlib import Path from pyquery import PyQuery +from django.db.models import Q from django.urls import reverse as urlreverse from django.conf import settings from django.utils import timezone @@ -24,6 +25,7 @@ ConsensusDocEvent, LastCallDocEvent, RelatedDocument, State, TelechatDocEvent, WriteupDocEvent, DocRelationshipName, IanaExpertDocEvent ) from ietf.doc.utils import get_tags_for_stream_id, create_ballot_if_not_open +from ietf.doc.views_draft import AdoptDraftForm from ietf.name.models import StreamName, DocTagName from ietf.group.factories import GroupFactory, RoleFactory from ietf.group.models import Group, Role @@ -1598,84 +1600,206 @@ def test_release_ise_draft(self): class AdoptDraftTests(TestCase): def test_adopt_document(self): - RoleFactory(group__acronym='mars',group__list_email='mars-wg@ietf.org',person__user__username='marschairman',name_id='chair') - draft = IndividualDraftFactory(name='draft-ietf-mars-test',notify='aliens@example.mars') - - url = urlreverse('ietf.doc.views_draft.adopt_draft', kwargs=dict(name=draft.name)) - login_testing_unauthorized(self, "marschairman", url) - - # get - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - q = PyQuery(r.content) - self.assertEqual(len(q('form select[name="group"] option')), 1) # we can only select "mars" - - # adopt in mars WG - mailbox_before = len(outbox) - events_before = draft.docevent_set.count() - mars = Group.objects.get(acronym="mars") - call_issued = State.objects.get(type='draft-stream-ietf',slug='c-adopt') - r = self.client.post(url, - dict(comment="some comment", - group=mars.pk, - newstate=call_issued.pk, - weeks="10")) - self.assertEqual(r.status_code, 302) - - draft = Document.objects.get(pk=draft.pk) - self.assertEqual(draft.group.acronym, "mars") - self.assertEqual(draft.stream_id, "ietf") - self.assertEqual(draft.docevent_set.count() - events_before, 5) - self.assertEqual(draft.notify,"aliens@example.mars") - self.assertEqual(len(outbox), mailbox_before + 1) - self.assertTrue("Call For Adoption" in outbox[-1]["Subject"]) - self.assertTrue("mars-chairs@ietf.org" in outbox[-1]['To']) - self.assertTrue("draft-ietf-mars-test@" in outbox[-1]['To']) - self.assertTrue("mars-wg@" in outbox[-1]['To']) - - self.assertFalse(mars.list_email in draft.notify) - - def test_right_state_choices_offered(self): - draft = IndividualDraftFactory() - wg = GroupFactory(type_id='wg',state_id='active') - rg = GroupFactory(type_id='rg',state_id='active') - person = PersonFactory(user__username='person') + stream_state_type_slug = { + "wg": "draft-stream-ietf", + "ag": "draft-stream-ietf", + "rg": "draft-stream-irtf", + "rag": "draft-stream-irtf", + "edwg": "draft-stream-editorial", + } + for type_id in ("wg", "ag", "rg", "rag", "edwg"): + chair_role = RoleFactory(group__type_id=type_id,name_id='chair') + draft = IndividualDraftFactory(notify=f'{type_id}group@example.mars') + + url = urlreverse('ietf.doc.views_draft.adopt_draft', kwargs=dict(name=draft.name)) + self.client.logout() + login_testing_unauthorized(self, chair_role.person.user.username, url) + + # get + r = self.client.get(url) + self.assertEqual(r.status_code, 200) - self.client.login(username='person',password='person+password') - url = urlreverse('ietf.doc.views_draft.adopt_draft', kwargs=dict(name=draft.name)) + # call for adoption + group_type_can_call_for_adoption = State.objects.filter(type_id=stream_state_type_slug[type_id],slug="c-adopt").exists() + if group_type_can_call_for_adoption: + empty_outbox() + events_before = draft.docevent_set.count() + call_issued = State.objects.get(type=stream_state_type_slug[type_id],slug='c-adopt') + r = self.client.post(url, + dict(comment="some comment", + group=chair_role.group.pk, + newstate=call_issued.pk, + weeks="10")) + self.assertEqual(r.status_code, 302) + + draft = Document.objects.get(pk=draft.pk) + self.assertEqual(draft.get_state_slug(stream_state_type_slug[type_id]), "c-adopt") + self.assertEqual(draft.group, chair_role.group) + self.assertEqual(draft.stream_id, stream_state_type_slug[type_id][13:]) # trim off "draft-stream-" + self.assertEqual(draft.docevent_set.count() - events_before, 5) + self.assertEqual(len(outbox), 1) + self.assertTrue("Call For Adoption" in outbox[-1]["Subject"]) + self.assertTrue(f"{chair_role.group.acronym}-chairs@" in outbox[-1]['To']) + self.assertTrue(f"{draft.name}@" in outbox[-1]['To']) + self.assertTrue(f"{chair_role.group.acronym}@" in outbox[-1]['To']) + + # adopt + empty_outbox() + events_before = draft.docevent_set.count() + # There are several possible states that a stream can adopt into - we will only test one per stream + stream_adopt_state_slug = "wg-doc" if type_id in ("wg", "ag") else "active" + stream_adopt_state = State.objects.get(type=stream_state_type_slug[type_id],slug=stream_adopt_state_slug) + r = self.client.post(url, + dict(comment="some comment", + group=chair_role.group.pk, + newstate=stream_adopt_state.pk, + weeks="10")) + self.assertEqual(r.status_code, 302) - person.role_set.create(name_id='chair',group=wg,email=person.email()) - r = self.client.get(url) - q = PyQuery(r.content) - self.assertTrue('(IETF)' in q('#id_newstate option').text()) - self.assertFalse('(IRTF)' in q('#id_newstate option').text()) + draft = Document.objects.get(pk=draft.pk) + self.assertEqual(draft.get_state_slug(stream_state_type_slug[type_id]), stream_adopt_state_slug) + self.assertEqual(draft.group, chair_role.group) + self.assertEqual(draft.stream_id, stream_state_type_slug[type_id][13:]) # trim off "draft-stream-" + if type_id in ("wg", "ag"): + self.assertEqual( + Counter(list(draft.docevent_set.values_list('type',flat=True))[events_before:]), + Counter({'changed_group': 1, 'changed_stream': 1, 'new_revision': 1}) + ) + else: + self.assertEqual( + Counter(list(draft.docevent_set.values_list('type',flat=True))[events_before:]), + Counter({'changed_state': 1, 'added_comment': 1, 'changed_group': 1, 'changed_document': 1, 'changed_stream': 1, 'new_revision': 1}) + ) + self.assertEqual(len(outbox), 1 if type_id in ["wg", "ag"] else 2) + self.assertTrue(stream_adopt_state.name in outbox[-1]["Subject"]) + self.assertTrue(f"{chair_role.group.acronym}-chairs@" in outbox[-1]['To']) + self.assertTrue(f"{draft.name}@" in outbox[-1]['To']) + self.assertTrue(f"{chair_role.group.acronym}@" in outbox[-1]['To']) + if type_id not in ["wg", "ag"]: + self.assertTrue(outbox[-2]["Subject"].endswith("to Informational")) + # recipient fields tested elsewhere + + +class AdoptDraftFormTests(TestCase): + def setUp(self): + super().setUp() + # test_data.py made a WG already, and made all the GroupFeatures + # This will detect changes in that assumption + self.chair_roles = { + "wg": Group.objects.filter( + type__features__acts_like_wg=True, state="active" + ) + .get() + .role_set.get(name_id="chair") + } + # This set of tests currently assumes all document adopting group types have "chair" in thier docman roles, + # and only tests that the form acts correctly for chairs. It should be expanded to use all the roles it finds + # in the group of docman roles (which comes from the production database by way of ietf/name/fixtures/names.json) + for type_id in ["ag", "rg", "rag", "edwg"]: + self.chair_roles[type_id] = RoleFactory( + group__type_id=type_id, name_id="chair" + ) - person.role_set.create(name_id='chair',group=Group.objects.get(acronym='irtf'),email=person.email()) - r = self.client.get(url) - q = PyQuery(r.content) - self.assertTrue('(IETF)' in q('#id_newstate option').text()) - self.assertTrue('(IRTF)' in q('#id_newstate option').text()) + def test_form_init(self): + secretariat = Person.objects.get(user__username="secretary") + f = AdoptDraftForm(user=secretariat.user) + form_offers_groups = f.fields["group"].queryset + self.assertEqual( + set(form_offers_groups.all()), + set( + Group.objects.filter(type__features__acts_like_wg=True, state="active") + ), + ) + self.assertEqual(form_offers_groups.count(), 5) + form_offers_states = State.objects.filter( + pk__in=[t[0] for t in f.fields["newstate"].choices[1:]] + ) + self.assertEqual( + Counter(form_offers_states.values_list("type_id", flat=True)), + Counter( + { + "draft-stream-irtf": 14, + "draft-stream-ietf": 12, + "draft-stream-editorial": 5, + } + ), + ) - person.role_set.filter(group__acronym='irtf').delete() - person.role_set.create(name_id='chair',group=rg,email=person.email()) - r = self.client.get(url) - q = PyQuery(r.content) - self.assertTrue('(IETF)' in q('#id_newstate option').text()) - self.assertTrue('(IRTF)' in q('#id_newstate option').text()) + irtf_chair = Person.objects.get(user__username="irtf-chair") + f = AdoptDraftForm(user=irtf_chair.user) + form_offers_groups = f.fields["group"].queryset + self.assertEqual( + set(form_offers_groups.all()), + set(Group.objects.filter(type_id__in=("rag", "rg"), state="active")), + ) + self.assertEqual(form_offers_groups.count(), 2) + form_offers_states = State.objects.filter( + pk__in=[t[0] for t in f.fields["newstate"].choices[1:]] + ) + self.assertEqual( + set(form_offers_states.values_list("type_id", flat=True)), + set(["draft-stream-irtf"]), + ) - person.role_set.filter(group=wg).delete() - r = self.client.get(url) - q = PyQuery(r.content) - self.assertFalse('(IETF)' in q('#id_newstate option').text()) - self.assertTrue('(IRTF)' in q('#id_newstate option').text()) + stream_state_type_slug = { + "wg": "draft-stream-ietf", + "ag": "draft-stream-ietf", + "rg": "draft-stream-irtf", + "rag": "draft-stream-irtf", + "edwg": "draft-stream-editorial", + } + for type_id in self.chair_roles: + f = AdoptDraftForm(user=self.chair_roles[type_id].person.user) + form_offers_groups = f.fields["group"].queryset + self.assertEqual(form_offers_groups.get(), self.chair_roles[type_id].group) + form_offers_states = State.objects.filter( + pk__in=[t[0] for t in f.fields["newstate"].choices[1:]] + ) + self.assertEqual( + set(form_offers_states.values_list("type_id", flat=True)), + set([stream_state_type_slug[type_id]]), + ) - person.role_set.all().delete() - person.role_set.create(name_id='secr',group=Group.objects.get(acronym='secretariat'),email=person.email()) - r = self.client.get(url) - q = PyQuery(r.content) - self.assertTrue('(IETF)' in q('#id_newstate option').text()) - self.assertTrue('(IRTF)' in q('#id_newstate option').text()) + edwgchair_role = self.chair_roles["edwg"] + RoleFactory(group__type_id="wg", person=edwgchair_role.person, name_id="chair") + RoleFactory(group__type_id="rg", person=edwgchair_role.person, name_id="chair") + f = AdoptDraftForm(user=edwgchair_role.person.user) + form_offers_groups = f.fields["group"].queryset + self.assertEqual( + set(form_offers_groups.values_list("type_id", flat=True)), + set(["edwg", "wg", "rg"]), + ) + self.assertEqual(form_offers_groups.count(), 3) + form_offers_states = State.objects.filter( + pk__in=[t[0] for t in f.fields["newstate"].choices[1:]] + ) + self.assertEqual( + set(form_offers_states.values_list("type_id", flat=True)), + set(["draft-stream-irtf", "draft-stream-ietf", "draft-stream-editorial"]), + ) + also_chairs_wg = RoleFactory( + group__type_id="wg", person=irtf_chair, name_id="chair" + ) + f = AdoptDraftForm(user=irtf_chair.user) + form_offers_groups = f.fields["group"].queryset + self.assertEqual( + set(form_offers_groups.all()), + set( + Group.objects.filter( + Q(type_id__in=("rag", "rg")) | Q(pk=also_chairs_wg.group.pk), + state="active", + ) + ), + ) + self.assertEqual(form_offers_groups.count(), 4) + form_offers_states = State.objects.filter( + pk__in=[t[0] for t in f.fields["newstate"].choices[1:]] + ) + self.assertEqual( + set(form_offers_states.values_list("type_id", flat=True)), + set(["draft-stream-irtf", "draft-stream-ietf"]), + ) class ChangeStreamStateTests(TestCase): def test_set_tags(self): diff --git a/ietf/doc/tests_irsg_ballot.py b/ietf/doc/tests_irsg_ballot.py index da1b48fc6c..92752e48c4 100644 --- a/ietf/doc/tests_irsg_ballot.py +++ b/ietf/doc/tests_irsg_ballot.py @@ -446,7 +446,7 @@ def setUp(self): def test_cant_issue_irsg_ballot(self): draft = RgDraftFactory() due = datetime_today(DEADLINE_TZINFO) + datetime.timedelta(days=14) - url = urlreverse('ietf.doc.views_ballot.close_irsg_ballot', kwargs=dict(name=draft.name)) + url = urlreverse('ietf.doc.views_ballot.issue_irsg_ballot', kwargs=dict(name=draft.name)) self.client.login(username = self.username, password = self.username+'+password') r = self.client.get(url) diff --git a/ietf/doc/tests_rsab_ballot.py b/ietf/doc/tests_rsab_ballot.py new file mode 100644 index 0000000000..ab2a58c004 --- /dev/null +++ b/ietf/doc/tests_rsab_ballot.py @@ -0,0 +1,601 @@ +# Copyright The IETF Trust 2022, All Rights Reserved +# -*- coding: utf-8 -*- + +# import datetime +# from pyquery import PyQuery + +import debug # pyflakes:ignore + +from django.urls import reverse as urlreverse + +from ietf.utils.mail import outbox, empty_outbox, get_payload_text +from ietf.utils.test_utils import TestCase, unicontent, login_testing_unauthorized +from ietf.doc.factories import ( + EditorialDraftFactory, + EditorialRfcFactory, + IndividualDraftFactory, + WgDraftFactory, + RgDraftFactory, + BallotDocEventFactory, + IRSGBallotDocEventFactory, + BallotPositionDocEventFactory, +) +from ietf.doc.models import BallotPositionDocEvent +from ietf.doc.utils import create_ballot_if_not_open, close_ballot +from ietf.person.utils import get_active_rsab, get_active_ads, get_active_irsg +from ietf.group.factories import RoleFactory +from ietf.group.models import Group, Role +from ietf.person.models import Person + + +class IssueRSABBallotTests(TestCase): + def test_issue_ballot_button_presence(self): + + individual_draft = IndividualDraftFactory() + wg_draft = WgDraftFactory() + rg_draft = RgDraftFactory() + ed_draft = EditorialDraftFactory() + ed_rfc = EditorialRfcFactory() + + # login as an RSAB chair + self.client.login(username="rsab-chair", password="rsab-chair+password") + + for name in [ + doc.canonical_name() + for doc in (individual_draft, wg_draft, rg_draft, ed_rfc) + ]: + url = urlreverse("ietf.doc.views_doc.document_main", kwargs=dict(name=name)) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertNotIn("Issue RSAB ballot", unicontent(r)) + + url = urlreverse( + "ietf.doc.views_doc.document_main", kwargs=dict(name=ed_draft.name) + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertIn("Issue RSAB ballot", unicontent(r)) + + self.client.logout() + url = urlreverse( + "ietf.doc.views_doc.document_main", kwargs=dict(name=ed_draft.name) + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertNotIn("Issue RSAB ballot", unicontent(r)) + + def test_close_ballot_button_presence(self): + individual_draft = IndividualDraftFactory() + wg_draft = WgDraftFactory() + rg_draft = RgDraftFactory() + ed_draft = EditorialDraftFactory() + ed_rfc = EditorialRfcFactory() + iesgmember = get_active_ads()[0] + irsgmember = get_active_irsg()[0] + + BallotDocEventFactory(doc=ed_draft, ballot_type__slug="rsab-approve") + + url = urlreverse( + "ietf.doc.views_doc.document_main", kwargs=dict(name=ed_draft.name) + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertNotIn("Close RSAB ballot", unicontent(r)) + + # Login as other body balloters to see if the ballot close button appears + for member in (iesgmember, irsgmember): + url = urlreverse( + "ietf.doc.views_doc.document_main", kwargs=dict(name=ed_draft.name) + ) + self.client.login( + username=member.user.username, + password=member.user.username + "+password", + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertNotIn("Close RSAB ballot", unicontent(r)) + + # Try to get the ballot closing page directly + url = urlreverse( + "ietf.doc.views_ballot.close_rsab_ballot", + kwargs=dict(name=ed_draft.name), + ) + r = self.client.get(url) + self.assertNotEqual(r.status_code, 200) + self.client.logout() + + # Login as the RSAB chair + self.client.login(username="rsab-chair", password="rsab-chair+password") + + # The close button should now be available + url = urlreverse( + "ietf.doc.views_doc.document_main", kwargs=dict(name=ed_draft.name) + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertIn("Close RSAB ballot", unicontent(r)) + + # Get the page with the Close RSAB Ballot Yes/No buttons + url = urlreverse( + "ietf.doc.views_ballot.close_rsab_ballot", kwargs=dict(name=ed_draft.name) + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + # Login as the Secretariat + self.client.logout() + self.client.login(username="secretary", password="secretary+password") + + # The close button should be available + url = urlreverse( + "ietf.doc.views_doc.document_main", kwargs=dict(name=ed_draft.name) + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertIn("Close RSAB ballot", unicontent(r)) + + # Individual, IETF, and RFC docs should not show the Close button. + for draft in (individual_draft, wg_draft, rg_draft, ed_rfc): + url = urlreverse( + "ietf.doc.views_doc.document_main", kwargs=dict(name=draft.name) + ) + r = self.client.get(url, follow=True) + self.assertEqual(r.status_code, 200) + self.assertNotIn("Close RSAB ballot", unicontent(r)) + + # TODO: This has a lot of redundancy with the BaseManipulation tests that should be refactored to speed tests up. + def test_issue_ballot(self): + + ed_draft1 = EditorialDraftFactory() + ed_draft2 = EditorialDraftFactory() + iesgmember = get_active_ads()[0] + + self.assertFalse(ed_draft1.ballot_open("rsab-approve")) + + self.client.login(username="rsab-chair", password="rsab-chair+password") + url = urlreverse( + "ietf.doc.views_ballot.issue_rsab_ballot", kwargs=dict(name=ed_draft1.name) + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + # Buttons present? + self.assertIn("rsab_button", unicontent(r)) + + # Press the No button - expect nothing but a redirect back to the draft's main page + r = self.client.post(url, dict(rsab_button="No")) + self.assertEqual(r.status_code, 302) + + # Press the Yes button + r = self.client.post(url, dict(rsab_button="Yes")) + self.assertEqual(r.status_code, 302) + self.assertTrue(ed_draft1.ballot_open("rsab-approve")) + + # Having issued a ballot, the Issue RSAB ballot button should be gone + url = urlreverse( + "ietf.doc.views_doc.document_main", kwargs=dict(name=ed_draft1.name) + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertNotIn("Issue RSAB ballot", unicontent(r)) + + # The RSAB evaluation record tab should exist + self.assertIn("RSAB evaluation record", unicontent(r)) + # The RSAB evaluation record tab should not indicate unavailability + self.assertNotIn( + "RSAB Evaluation Ballot has not been created yet", unicontent(r) + ) # TODO: why is this a thing? We don't ever show the tab unless there's a ballot. May need to reconsider how we treat the IESG. + + # We should find an RSAB member's name on the RSAB evaluation tab regardless of any positions taken or not + url = urlreverse( + "ietf.doc.views_doc.document_rsab_ballot", kwargs=dict(name=ed_draft1.name) + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + rsabmembers = get_active_rsab() + self.assertNotEqual(len(rsabmembers), 0) + for member in rsabmembers: + self.assertIn(member.name, unicontent(r)) + + # Having issued a ballot, it should appear on the RSAB Ballot Status page + url = urlreverse("ietf.doc.views_ballot.rsab_ballot_status") + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + # Does the draft name appear on the page? + self.assertIn(ed_draft1.name, unicontent(r)) + + self.client.logout() + + # Test that an IESG member cannot issue an RSAB ballot + self.client.login( + username=iesgmember.user.username, + password=iesgmember.user.username + "password", + ) + + url = urlreverse( + "ietf.doc.views_ballot.issue_rsab_ballot", kwargs=dict(name=ed_draft2.name) + ) + r = self.client.get(url) + self.assertNotEqual(r.status_code, 200) + # Buttons present? + self.assertNotIn("rsab_button", unicontent(r)) + + # Attempt to press the Yes button anyway + r = self.client.post(url, dict(rsab_button="Yes")) + self.assertTrue(r.status_code == 302 and "/accounts/login" in r["Location"]) + + def test_edit_ballot_position_permissions(self): + ed_draft = EditorialDraftFactory() + ad = RoleFactory(group__type_id="area", name_id="ad") + pre_ad = RoleFactory(group__type_id="area", name_id="pre-ad") + irsgmember = get_active_irsg()[0] + rsab_chair = Role.objects.get(group__acronym="rsab", name="chair") + ballot = create_ballot_if_not_open( + None, ed_draft, rsab_chair.person, "rsab-approve" + ) + + url = urlreverse( + "ietf.doc.views_ballot.edit_position", + kwargs=dict(name=ed_draft.name, ballot_id=ballot.pk), + ) + + for person in (ad.person, pre_ad.person, irsgmember): + self.client.login( + username=person.user.username, + password=f"{person.user.username}+password", + ) + r = self.client.post( + url, dict(position="concern", discuss="Test discuss text") + ) + self.assertEqual(r.status_code, 403) + self.client.logout() + + def test_iesg_ballot_no_rsab_actions(self): + ad = Person.objects.get(user__username="ad") + wg_draft = IndividualDraftFactory(ad=ad) + RoleFactory.create_batch( + 2, name_id="member", group=Group.objects.get(acronym="rsab") + ) + rsabmember = get_active_rsab()[0] + + url = urlreverse( + "ietf.doc.views_ballot.ballot_writeupnotes", kwargs=dict(name=wg_draft.name) + ) + + # RSAB members should not be able to issue IESG ballots + login_testing_unauthorized(self, rsabmember.user.username, url) + r = self.client.post( + url, dict(ballot_writeup="This is a test.", issue_ballot="1") + ) + self.assertNotEqual(r.status_code, 200) + + self.client.logout() + login_testing_unauthorized(self, "ad", url) + + BallotDocEventFactory(doc=wg_draft) + + # rsab members (who are not also IESG members) can't take positions + ballot = wg_draft.active_ballot() + url = urlreverse( + "ietf.doc.views_ballot.edit_position", + kwargs=dict(name=wg_draft.name, ballot_id=ballot.pk), + ) + self.client.logout() + login_testing_unauthorized(self, rsabmember.user.username, url) + + r = self.client.post(url, dict(position="discuss", discuss="Test discuss text")) + self.assertEqual(r.status_code, 403) + + +class BaseManipulationTests: + def test_issue_ballot(self): + draft = EditorialDraftFactory() + url = urlreverse( + "ietf.doc.views_ballot.issue_rsab_ballot", kwargs=dict(name=draft.name) + ) + empty_outbox() + + login_testing_unauthorized(self, self.username, url) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + r = self.client.post(url, {"rsab_button": "No"}) + self.assertEqual(r.status_code, 302) + self.assertIsNone(draft.ballot_open("rsab-approve")) + + # No notifications should have been generated yet + self.assertEqual(len(outbox), 0) + + r = self.client.post(url, {"rsab_button": "Yes"}) + self.assertEqual(r.status_code, 302) + self.assertIsNotNone(draft.ballot_open("rsab-approve")) + + # Should have sent a notification about the new ballot + self.assertEqual(len(outbox), 1) + msg = outbox[0] + self.assertIn("RSAB ballot issued", msg["Subject"]) + self.assertIn("iesg-secretary@ietf.org", msg["From"]) + self.assertIn(draft.name, msg["Cc"]) + self.assertIn("rsab@rfc-editor.org", msg["To"]) + + def test_take_and_email_position(self): + draft = EditorialDraftFactory() + ballot = BallotDocEventFactory(doc=draft, ballot_type__slug="rsab-approve") + url = ( + urlreverse( + "ietf.doc.views_ballot.edit_position", + kwargs=dict(name=draft.name, ballot_id=ballot.pk), + ) + + self.balloter + ) + empty_outbox() + + login_testing_unauthorized(self, self.username, url) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + r = self.client.post( + url, + dict(position="yes", comment="oib239sb", send_mail="Save and send email"), + ) + self.assertEqual(r.status_code, 302) + e = draft.latest_event(BallotPositionDocEvent) + self.assertEqual(e.pos.slug, "yes") + self.assertEqual(e.comment, "oib239sb") + + url = ( + urlreverse( + "ietf.doc.views_ballot.send_ballot_comment", + kwargs=dict(name=draft.name, ballot_id=ballot.pk), + ) + + self.balloter + ) + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + r = self.client.post( + url, + dict( + cc_choices=["doc_authors", "doc_group_chairs", "doc_group_mail_list"], + body="Stuff", + ), + ) + self.assertEqual(r.status_code, 302) + self.assertEqual(len(outbox), 1) + self.assertNotIn("discuss-criteria", get_payload_text(outbox[0])) + + def test_close_ballot(self): + draft = EditorialDraftFactory() + BallotDocEventFactory(doc=draft, ballot_type__slug="rsab-approve") + url = urlreverse( + "ietf.doc.views_ballot.close_rsab_ballot", kwargs=dict(name=draft.name) + ) + empty_outbox() + + login_testing_unauthorized(self, self.username, url) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + r = self.client.post(url, dict(rsab_button="No")) + self.assertEqual(r.status_code, 302) + self.assertIsNotNone(draft.ballot_open("rsab-approve")) + + # Should not have generated a notification yet + self.assertEqual(len(outbox), 0) + + r = self.client.post(url, dict(rsab_button="Yes")) + self.assertEqual(r.status_code, 302) + self.assertIsNone(draft.ballot_open("rsab-approve")) + + # Closing the ballot should have generated a notification + self.assertEqual(len(outbox), 1) + msg = outbox[0] + self.assertIn("RSAB ballot closed", msg["Subject"]) + self.assertIn("iesg-secretary@ietf.org", msg["From"]) + self.assertIn("rsab@rfc-editor.org", msg["To"]) + self.assertIn(f"{draft.name}@ietf.org", msg["Cc"]) + + def test_view_outstanding_ballots(self): + draft = EditorialDraftFactory() + BallotDocEventFactory(doc=draft, ballot_type__slug="rsab-approve") + url = urlreverse("ietf.doc.views_ballot.rsab_ballot_status") + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertIn(draft.name, unicontent(r)) + + close_ballot( + draft, Person.objects.get(user__username=self.username), "rsab-approve" + ) + r = self.client.get(url) + self.assertNotIn(draft.name, unicontent(r)) + + +class RSABChairTests(BaseManipulationTests, TestCase): + def setUp(self): + super().setUp() + self.username = "rsab-chair" + self.balloter = "" + + +class SecretariatTests(BaseManipulationTests, TestCase): + def setUp(self): + super().setUp() + self.username = "secretary" + self.balloter = "?balloter={}".format( + Person.objects.get(user__username="rsab-chair").pk + ) + + +class RSABMemberTests(TestCase): + def setUp(self): + super().setUp() + self.username = RoleFactory( + group__acronym="rsab", name_id="member" + ).person.user.username + + def test_cant_issue_rsab_ballot(self): + draft = EditorialDraftFactory() + url = urlreverse( + "ietf.doc.views_ballot.issue_rsab_ballot", kwargs=dict(name=draft.name) + ) + + self.client.login(username=self.username, password=self.username + "+password") + r = self.client.get(url) + self.assertEqual(r.status_code, 403) + + r = self.client.post(url, {"rsab_button": "Yes"}) + self.assertEqual(r.status_code, 403) + + def test_cant_close_rsab_ballot(self): + draft = EditorialDraftFactory() + BallotDocEventFactory(doc=draft, ballot_type__slug="rsab-approve") + url = urlreverse( + "ietf.doc.views_ballot.close_rsab_ballot", kwargs=dict(name=draft.name) + ) + + self.client.login(username=self.username, password=self.username + "+password") + r = self.client.get(url) + self.assertEqual(r.status_code, 403) + + r = self.client.post(url, dict(rsab_button="Yes")) + self.assertEqual(r.status_code, 403) + + def test_cant_act_on_other_bodies_ballots(self): + ietf_doc = WgDraftFactory() + irtf_doc = RgDraftFactory() + + self.client.login(username=self.username, password=f"{self.username}+password") + + url = urlreverse( + "ietf.doc.views_ballot.ballot_writeupnotes", kwargs=dict(name=ietf_doc.name) + ) + self.assertEqual(self.client.get(url).status_code, 403) + self.assertEqual( + self.client.post( + url, + dict(ballot_writeup="This is a simple test.", save_ballot_writeup="1"), + ).status_code, + 403, + ) + + url = urlreverse( + "ietf.doc.views_ballot.issue_irsg_ballot", kwargs=dict(name=irtf_doc.name) + ) + self.assertEqual(self.client.get(url).status_code, 403) + self.assertEqual( + self.client.post( + url, dict(irsg_button="Yes", duedate="2038-01-19") + ).status_code, + 403, + ) + + for name, ballot_id in [ + (ietf_doc.name, BallotDocEventFactory(doc=ietf_doc).pk), + (irtf_doc.name, IRSGBallotDocEventFactory(doc=irtf_doc).pk), + ]: + url = urlreverse( + "ietf.doc.views_ballot.edit_position", + kwargs=dict(name=name, ballot_id=ballot_id), + ) + self.assertEqual( + self.client.get(url).status_code, 200 + ) # TODO : WHAT?! : This is strange, and probably tied up badly with pre-ad, and it should change. + self.assertEqual( + self.client.post( + url, + dict(position="yes"), + ).status_code, + 403, + ) + + url = urlreverse( + "ietf.doc.views_ballot.close_irsg_ballot", kwargs=dict(name=irtf_doc.name) + ) + self.assertEqual(self.client.get(url).status_code, 403) + self.assertEqual( + self.client.post(url, dict(irsg_button="Yes")).status_code, 403 + ) + + # Closing iesg ballots happens as a side-effect of secretariat actions with access testing done elsewhere + + def test_take_and_email_position(self): + draft = EditorialDraftFactory() + ballot = BallotDocEventFactory(doc=draft, ballot_type__slug="rsab-approve") + url = urlreverse( + "ietf.doc.views_ballot.edit_position", + kwargs=dict(name=draft.name, ballot_id=ballot.pk), + ) + empty_outbox() + + login_testing_unauthorized(self, self.username, url) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + r = self.client.post( + url, + dict(position="yes", comment="oib239sb", send_mail="Save and send email"), + ) + self.assertEqual(r.status_code, 302) + e = draft.latest_event(BallotPositionDocEvent) + self.assertEqual(e.pos.slug, "yes") + self.assertEqual(e.comment, "oib239sb") + + url = urlreverse( + "ietf.doc.views_ballot.send_ballot_comment", + kwargs=dict(name=draft.name, ballot_id=ballot.pk), + ) + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + r = self.client.post( + url, + dict( + cc_choices=["doc_authors", "doc_group_chairs", "doc_group_mail_list"], + body="Stuff", + ), + ) + self.assertEqual(r.status_code, 302) + self.assertEqual(len(outbox), 1) + + +class NobodyTests(TestCase): + def setUp(self): + super().setUp() + self.draft = EditorialDraftFactory() + self.ballot = BallotDocEventFactory( + doc=self.draft, ballot_type__slug="rsab-approve" + ) + BallotPositionDocEventFactory( + ballot=self.ballot, + by=get_active_rsab()[0], + pos_id="yes", + comment="b2390sn3", + ) + + def can_see_RSAB_tab(self): + url = urlreverse( + "ietf.doc.views_doc.document_rsab_ballot", kwargs=dict(name=self.draft.name) + ) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self.assertIn("b2390sn3", unicontent(r)) + + def test_cant_take_position_on_irtf_ballot(self): + + url = urlreverse( + "ietf.doc.views_ballot.edit_position", + kwargs=dict(name=self.draft.name, ballot_id=self.ballot.pk), + ) + + r = self.client.get(url) + self.assertEqual(r.status_code, 302) + self.assertIn("/accounts/login", r["Location"]) + + r = self.client.post( + url, + dict(position="yes", comment="oib239sb", send_mail="Save and send email"), + ) + self.assertEqual(r.status_code, 302) + self.assertIn("/accounts/login", r["Location"]) diff --git a/ietf/doc/urls.py b/ietf/doc/urls.py index 65b8bb4d17..dd43dc4bbd 100644 --- a/ietf/doc/urls.py +++ b/ietf/doc/urls.py @@ -96,6 +96,7 @@ url(r'^recent/?$', views_search.recent_drafts), url(r'^select2search/(?P(document|docalias))/(?Pdraft)/$', views_search.ajax_select2_search_docs), url(r'^ballots/irsg/$', views_ballot.irsg_ballot_status), + url(r'^ballots/rsab/$', views_ballot.rsab_ballot_status), url(r'^%(name)s(?:/%(rev)s)?/$' % settings.URL_REGEXPS, views_doc.document_main), url(r'^%(name)s(?:/%(rev)s)?/bibtex/$' % settings.URL_REGEXPS, views_doc.document_bibtex), @@ -110,6 +111,7 @@ url(r'^%(name)s/referencedby/$' % settings.URL_REGEXPS, views_doc.document_referenced_by), url(r'^%(name)s/ballot/(iesg/)?$' % settings.URL_REGEXPS, views_doc.document_ballot), url(r'^%(name)s/ballot/irsg/$' % settings.URL_REGEXPS, views_doc.document_irsg_ballot), + url(r'^%(name)s/ballot/rsab/$' % settings.URL_REGEXPS, views_doc.document_rsab_ballot), url(r'^%(name)s/ballot/(?P[0-9]+)/$' % settings.URL_REGEXPS, views_doc.document_ballot), url(r'^%(name)s/ballot/(?P[0-9]+)/position/$' % settings.URL_REGEXPS, views_ballot.edit_position), url(r'^%(name)s/ballot/(?P[0-9]+)/emailposition/$' % settings.URL_REGEXPS, views_ballot.send_ballot_comment), @@ -161,6 +163,8 @@ url(r'^%(name)s/edit/resources/$' % settings.URL_REGEXPS, views_draft.edit_doc_extresources), url(r'^%(name)s/edit/issueballot/irsg/$' % settings.URL_REGEXPS, views_ballot.issue_irsg_ballot), url(r'^%(name)s/edit/closeballot/irsg/$' % settings.URL_REGEXPS, views_ballot.close_irsg_ballot), + url(r'^%(name)s/edit/issueballot/rsab/$' % settings.URL_REGEXPS, views_ballot.issue_rsab_ballot), + url(r'^%(name)s/edit/closeballot/rsab/$' % settings.URL_REGEXPS, views_ballot.close_rsab_ballot), url(r'^help/state/(?P[\w-]+)/$', views_help.state_help), url(r'^help/relationships/$', views_help.relationship_help), diff --git a/ietf/doc/utils.py b/ietf/doc/utils.py index 887470eede..e76adb50db 100644 --- a/ietf/doc/utils.py +++ b/ietf/doc/utils.py @@ -11,7 +11,7 @@ import re import textwrap -from collections import defaultdict, namedtuple +from collections import defaultdict, namedtuple, Counter from typing import Union from urllib.parse import quote from zoneinfo import ZoneInfo @@ -35,7 +35,7 @@ from ietf.doc.models import DocEvent, ConsensusDocEvent, BallotDocEvent, IRSGBallotDocEvent, NewRevisionDocEvent, StateDocEvent from ietf.doc.models import TelechatDocEvent, DocumentActionHolder, EditedAuthorsDocEvent from ietf.name.models import DocReminderTypeName, DocRelationshipName -from ietf.group.models import Role, Group +from ietf.group.models import Role, Group, GroupFeatures from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream, is_individual_draft_author, is_bofreq_editor from ietf.person.models import Person from ietf.review.models import ReviewWish @@ -118,6 +118,10 @@ def get_tags_for_stream_id(stream_id): return [] def can_adopt_draft(user, doc): + """Answers whether a user can adopt a given draft into some stream/group. + + This does not answer, even by implicaiton, which streams/groups the user has authority to adopt into.""" + if not user.is_authenticated: return False @@ -129,17 +133,29 @@ def can_adopt_draft(user, doc): return (doc.stream_id in (None, "irtf") and doc.group.type_id == "individ") - roles = Role.objects.filter(name__in=("chair", "delegate", "secr"), - group__type__in=("wg", "rg", "ag", "rag"), - group__state="active", - person__user=user) - role_groups = [ r.group for r in roles ] + for type_id, allowed_stream in ( + ("wg", "ietf"), + ("rg", "irtf"), + ("ag", "ietf"), + ("rag", "irtf"), + ("edwg", "editorial"), + ): + if doc.stream_id in (None, allowed_stream): + if doc.group.type_id in ("individ", type_id): + if Role.objects.filter( + name__in=GroupFeatures.objects.get(type_id=type_id).docman_roles, + group__type_id = type_id, + group__state = "active", + person__user = user, + ).exists(): + return True + + return False - return (doc.stream_id in (None, "ietf", "irtf") - and (doc.group.type_id == "individ" or (doc.group in role_groups and len(role_groups)>1)) - and roles.exists()) def can_unadopt_draft(user, doc): + # TODO: This should use docman_roles, and this implementation probably returns wrong answers + # For instance, should any WG chair be able to unadopt a group from any other WG if not user.is_authenticated: return False if has_role(user, "Secretariat"): @@ -155,6 +171,8 @@ def can_unadopt_draft(user, doc): elif doc.stream_id == 'iab': return False # Right now only the secretariat can add a document to the IAB stream, so we'll # leave it where only the secretariat can take it out. + elif doc.stream_id == 'editorial': + return user.person.role_set.filter(name='chair', group__acronym='rswg').exists() else: return False @@ -222,7 +240,6 @@ def needed_ballot_positions(doc, active_positions): return " ".join(answer) -# Not done yet - modified version of above needed_ballot_positions def irsg_needed_ballot_positions(doc, active_positions): '''Returns text answering the question "what does this document need to pass?". The return value is only useful if the document @@ -250,6 +267,21 @@ def irsg_needed_ballot_positions(doc, active_positions): return " ".join(answer) +def rsab_needed_ballot_positions(doc, active_positions): + count = Counter([p.pos_id if p else 'none' for p in active_positions]) + answer = [] + if count["concern"] > 0: + answer.append("Has a Concern position.") + # note RFC9280 section 3.2.2 item 12 + # the "vote" mentioned there is a separate thing from ballot position. + if count["yes"] == 0: + # This is _implied_ by 9280 - a document shouldn't be + # approved if all RSAB members recuse + answer.append("Needs a YES position.") + if count["none"] > 0: + answer.append("Some members have have not taken a position.") + return " ".join(answer) + def create_ballot(request, doc, by, ballot_slug, time=None): closed = close_open_ballots(doc, by) for e in closed: @@ -265,16 +297,14 @@ def create_ballot(request, doc, by, ballot_slug, time=None): def create_ballot_if_not_open(request, doc, by, ballot_slug, time=None, duedate=None): ballot_type = BallotType.objects.get(doc_type=doc.type, slug=ballot_slug) if not doc.ballot_open(ballot_slug): + kwargs = dict(type="created_ballot", by=by, doc=doc, rev=doc.rev) if time: - if duedate: - e = IRSGBallotDocEvent(type="created_ballot", by=by, doc=doc, rev=doc.rev, time=time, duedate=duedate) - else: - e = BallotDocEvent(type="created_ballot", by=by, doc=doc, rev=doc.rev, time=time) + kwargs['time'] = time + if doc.stream_id == 'irtf': + kwargs['duedate'] = duedate + e = IRSGBallotDocEvent(**kwargs) else: - if duedate: - e = IRSGBallotDocEvent(type="created_ballot", by=by, doc=doc, rev=doc.rev, duedate=duedate) - else: - e = BallotDocEvent(type="created_ballot", by=by, doc=doc, rev=doc.rev) + e = BallotDocEvent(**kwargs) e.ballot_type = ballot_type e.desc = 'Created "%s" ballot' % e.ballot_type.name e.save() diff --git a/ietf/doc/views_ballot.py b/ietf/doc/views_ballot.py index 207b8b9723..67c42a37b3 100644 --- a/ietf/doc/views_ballot.py +++ b/ietf/doc/views_ballot.py @@ -27,6 +27,7 @@ extra_automation_headers, generate_last_call_announcement, generate_issue_ballot_mail, generate_ballot_writeup, generate_ballot_rfceditornote, generate_approval_mail, email_irsg_ballot_closed, email_irsg_ballot_issued, + email_rsab_ballot_issued, email_rsab_ballot_closed, email_lc_to_yang_doctors ) from ietf.doc.lastcall import request_last_call from ietf.doc.templatetags.ietf_filters import can_ballot @@ -43,16 +44,6 @@ from ietf.utils.timezone import date_today, datetime_from_date, DEADLINE_TZINFO -BALLOT_CHOICES = (("yes", "Yes"), - ("noobj", "No Objection"), - ("discuss", "Discuss"), - ("abstain", "Abstain"), - ("recuse", "Recuse"), - ("moretime", "Need More Time"), - ("notready", "Not Ready"), - ("", "No Record"), - ) - # ------------------------------------------------- # Helper Functions # ------------------------------------------------- @@ -106,6 +97,8 @@ def __init__(self, *args, **kwargs): ballot_type = kwargs.pop("ballot_type") super(EditPositionForm, self).__init__(*args, **kwargs) self.fields['position'].queryset = ballot_type.positions.order_by('order') + if ballot_type.positions.filter(blocking=True).exists(): + self.fields['discuss'].label = ballot_type.positions.get(blocking=True).name def clean_discuss(self): entered_discuss = self.cleaned_data["discuss"] @@ -183,9 +176,9 @@ def save_position(form, doc, ballot, balloter, login=None, send_email=False): return pos -@role_required('Area Director','Secretariat','IRSG Member') +@role_required("Area Director", "Secretariat", "IRSG Member", "RSAB Member") def edit_position(request, name, ballot_id): - """Vote and edit discuss and comment on document as Area Director.""" + """Vote and edit discuss and comment on document""" doc = get_object_or_404(Document, docalias__name=name) ballot = get_object_or_404(BallotDocEvent, type="created_ballot", pk=ballot_id, doc=doc) @@ -196,8 +189,7 @@ def edit_position(request, name, ballot_id): else: return_to_url = urlreverse("ietf.doc.views_doc.document_ballot", kwargs=dict(name=doc.name, ballot_id=ballot_id)) - # if we're in the Secretariat, we can select an AD to act as stand-in for - # or we can select an IRSG member + # if we're in the Secretariat, we can select a balloter to act as stand-in for if has_role(request.user, "Secretariat"): balloter_id = request.GET.get('balloter') if not balloter_id: @@ -207,8 +199,8 @@ def edit_position(request, name, ballot_id): if request.method == 'POST': old_pos = None if not has_role(request.user, "Secretariat") and not can_ballot(request.user, doc): - # prevent pre-ADs from voting - permission_denied(request, "Must be a proper Area Director in an active area or IRSG Member to cast ballot") + # prevent pre-ADs from taking a position + permission_denied(request, "Must be an active member (not a pre-AD for example) of the balloting body to take a position") form = EditPositionForm(request.POST, ballot_type=ballot.ballot_type) if form.is_valid(): @@ -328,16 +320,18 @@ def build_position_email(balloter, doc, pos): return addrs, frm, subject, body -@role_required('Area Director','Secretariat','IRSG Member') +@role_required('Area Director','Secretariat','IRSG Member', 'RSAB Member') def send_ballot_comment(request, name, ballot_id): """Email document ballot position discuss/comment for Area Director.""" doc = get_object_or_404(Document, docalias__name=name) ballot = get_object_or_404(BallotDocEvent, type="created_ballot", pk=ballot_id, doc=doc) if not has_role(request.user, 'Secretariat'): - if doc.stream_id == 'irtf' and not has_role(request.user, 'IRSG Member'): - raise Http404 - if doc.stream_id == 'ietf' and not has_role(request.user, 'Area Director'): + if any([ + doc.stream_id == 'ietf' and not has_role(request.user, 'Area Director'), + doc.stream_id == 'irtf' and not has_role(request.user, 'IRSG Member'), + doc.stream_id == 'editorial' and not has_role(request.user, 'RSAB Member'), + ]): raise Http404 balloter = request.user.person @@ -352,7 +346,7 @@ def send_ballot_comment(request, name, ballot_id): else: back_url = urlreverse("ietf.doc.views_doc.document_ballot", kwargs=dict(name=doc.name, ballot_id=ballot_id)) - # if we're in the Secretariat, we can select an AD to act as stand-in for + # if we're in the Secretariat, we can select a balloter (such as an AD) to act as stand-in for if has_role(request.user, "Secretariat"): balloter_id = request.GET.get('balloter') if not balloter_id: @@ -367,6 +361,8 @@ def send_ballot_comment(request, name, ballot_id): if doc.stream_id == 'irtf': mailtrigger_slug='irsg_ballot_saved' + elif doc.stream_id == 'editorial': + mailtrigger_slug='rsab_ballot_saved' else: mailtrigger_slug='iesg_ballot_saved' @@ -1200,3 +1196,90 @@ def irsg_ballot_status(request): docs.append(doc) return render(request, 'doc/irsg_ballot_status.html', {'docs':docs}) + +@role_required('Secretariat', 'RSAB Chair') +def issue_rsab_ballot(request, name): + doc = get_object_or_404(Document, docalias__name=name) + if doc.stream.slug != "editorial" or doc.type != DocTypeName.objects.get(slug="draft"): + raise Http404 + + by = request.user.person + + if request.method == 'POST': + button = request.POST.get("rsab_button") # TODO: Really? There's an irsg button? The templates should be generalized. + if button == 'Yes': + e = BallotDocEvent(doc=doc, rev=doc.rev, by=request.user.person) + e.type = "created_ballot" + e.desc = "Created RSAB Ballot" + ballot_type = BallotType.objects.get(doc_type=doc.type, slug="rsab-approve") + e.ballot_type = ballot_type + e.save() + new_state = doc.get_state() + prev_tags = [] + new_tags = [] + + email_rsab_ballot_issued(request, doc, ballot=e) # Send notification email + + if doc.type_id == 'draft': + new_state = State.objects.get(used=True, type="draft-stream-editorial", slug='rsabpoll') + + prev_state = doc.get_state(new_state.type_id if new_state else None) + + doc.set_state(new_state) + doc.tags.remove(*prev_tags) + + events = [] + e = add_state_change_event(doc, by, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags) + if e: + events.append(e) + e = update_action_holders(doc, prev_state, new_state, prev_tags=prev_tags, new_tags=new_tags) + if e: + events.append(e) + + if events: + doc.save_with_history(events) + + return HttpResponseRedirect(doc.get_absolute_url()) + else: + templ = 'doc/ballot/rsab_ballot_approve.html' + + question = "Confirm issuing a ballot for " + name + "?" + return render(request, templ, dict(doc=doc, question=question)) + +@role_required('Secretariat', 'RSAB Chair') +def close_rsab_ballot(request, name): + doc = get_object_or_404(Document, docalias__name=name) + if doc.stream.slug != "editorial" or doc.type_id != "draft": + raise Http404 + + by = request.user.person + + if request.method == 'POST': + button = request.POST.get("rsab_button") + if button == 'Yes': + ballot = close_ballot(doc, by, "rsab-approve") + email_rsab_ballot_closed( + request, + doc=doc, + ballot=BallotDocEvent.objects.get(pk=ballot.pk) + ) + return HttpResponseRedirect(doc.get_absolute_url()) + + templ = 'doc/ballot/rsab_ballot_close.html' + question = "Confirm closing the ballot for " + name + "?" + return render(request, templ, dict(doc=doc, question=question)) + +def rsab_ballot_status(request): + possible_docs = Document.objects.filter(docevent__ballotdocevent__isnull=False) + docs = [] + for doc in possible_docs: + if doc.ballot_open("rsab-approve"): + ballot = doc.active_ballot() + if ballot: + doc.ballot = ballot + + docs.append(doc) + return render(request, 'doc/rsab_ballot_status.html', {'docs':docs}) + # Possible TODO: add a menu item to show this? Maybe only if you're in rsab or an rswg chair? + # There will be so few of these that the general community would follow them from the rswg docs page. + # Maybe the view isn't actually needed at all... diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 29d0f0f03a..2426b3ef0e 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -70,7 +70,7 @@ from ietf.group.models import Role, Group from ietf.group.utils import can_manage_all_groups_of_type, can_manage_materials, group_features_role_filter from ietf.ietfauth.utils import ( has_role, is_authorized_in_doc_stream, user_is_person, - role_required, is_individual_draft_author) + role_required, is_individual_draft_author, can_request_rfc_publication) from ietf.name.models import StreamName, BallotPositionName from ietf.utils.history import find_history_active_at from ietf.doc.forms import TelechatForm, NotifyForm, ActionHoldersForm, DocAuthorForm, DocAuthorChangeBasisForm @@ -92,13 +92,28 @@ def render_document_top(request, doc, tab, name): tabs = [] tabs.append(("Status", "status", urlreverse("ietf.doc.views_doc.document_main", kwargs=dict(name=name)), True, None)) - iesg_type_slugs = set(BallotType.objects.values_list('slug',flat=True)) - iesg_type_slugs.discard('irsg-approve') + iesg_type_slugs = set(BallotType.objects.exclude(slug__in=("irsg-approve","rsab-approve")).values_list('slug',flat=True)) iesg_ballot = doc.latest_event(BallotDocEvent, type="created_ballot", ballot_type__slug__in=iesg_type_slugs) irsg_ballot = doc.latest_event(BallotDocEvent, type="created_ballot", ballot_type__slug='irsg-approve') + rsab_ballot = doc.latest_event(BallotDocEvent, type="created_ballot", ballot_type__slug='rsab-approve') - if doc.type_id == "draft" and doc.get_state("draft-stream-irtf"): - tabs.append(("IRSG Evaluation Record", "irsgballot", urlreverse("ietf.doc.views_doc.document_irsg_ballot", kwargs=dict(name=name)), irsg_ballot, None if irsg_ballot else "IRSG Evaluation Ballot has not been created yet")) + if doc.type_id == "draft": + if doc.get_state("draft-stream-irtf"): + tabs.append(( + "IRSG Evaluation Record", + "irsgballot", + urlreverse("ietf.doc.views_doc.document_irsg_ballot", kwargs=dict(name=name)), + irsg_ballot, + None if irsg_ballot else "IRSG Evaluation Ballot has not been created yet" + )) + if doc.get_state("draft-stream-editorial"): + tabs.append(( + "RSAB Evaluation Record", + "rsabballot", + urlreverse("ietf.doc.views_doc.document_rsab_ballot", kwargs=dict(name=name)), + rsab_ballot, + None if rsab_ballot else "RSAB Evaluation Ballot has not been created yet" + )) if doc.type_id in ("draft","conflrev", "statchg"): tabs.append(("IESG Evaluation Record", "ballot", urlreverse("ietf.doc.views_doc.document_ballot", kwargs=dict(name=name)), iesg_ballot, None if iesg_ballot else "IESG Evaluation Ballot has not been created yet")) elif doc.type_id == "charter" and doc.group.type_id == "wg": @@ -269,13 +284,11 @@ def document_main(request, name, rev=None, document_html=False): # ballot iesg_ballot_summary = None - irsg_ballot_summary = None due_date = None if (iesg_state_slug in IESG_BALLOT_ACTIVE_STATES) or irsg_state: active_ballot = doc.active_ballot() if active_ballot: if irsg_state: - irsg_ballot_summary = irsg_needed_ballot_positions(doc, list(active_ballot.active_balloter_positions().values())) due_date=active_ballot.irsgballotdocevent.duedate else: iesg_ballot_summary = needed_ballot_positions(doc, list(active_ballot.active_balloter_positions().values())) @@ -289,7 +302,7 @@ def document_main(request, name, rev=None, document_html=False): elif group.type_id == "area" and doc.stream_id == "ietf": submission = "individual in %s area" % group.acronym else: - if group.features.acts_like_wg: + if group.features.acts_like_wg and not group.type_id=="edwg": submission = "%s %s" % (group.acronym, group.type) else: submission = group.acronym @@ -390,12 +403,29 @@ def document_main(request, name, rev=None, document_html=False): if doc.get_state_slug() == "expired" and has_role(request.user, ("Secretariat",)) and not snapshot: actions.append(("Resurrect", urlreverse('ietf.doc.views_draft.resurrect', kwargs=dict(name=doc.name)))) - if (doc.get_state_slug() not in ["rfc", "expired"] and doc.stream_id in ("irtf",) and not snapshot and not doc.ballot_open('irsg-approve') and has_role(request.user, ("Secretariat", "IRTF Chair"))): - label = "Issue IRSG Ballot" - actions.append((label, urlreverse('ietf.doc.views_ballot.issue_irsg_ballot', kwargs=dict(name=doc.name)))) - if (doc.get_state_slug() not in ["rfc", "expired"] and doc.stream_id in ("irtf",) and not snapshot and doc.ballot_open('irsg-approve') and has_role(request.user, ("Secretariat", "IRTF Chair"))): - label = "Close IRSG Ballot" - actions.append((label, urlreverse('ietf.doc.views_ballot.close_irsg_ballot', kwargs=dict(name=doc.name)))) + if doc.get_state_slug() not in ["rfc", "expired"] and not snapshot: + if doc.stream_id == "irtf" and has_role(request.user, ("Secretariat", "IRTF Chair")): + if not doc.ballot_open('irsg-approve'): + actions.append(( + "Issue IRSG Ballot", + urlreverse('ietf.doc.views_ballot.issue_irsg_ballot', kwargs=dict(name=doc.name)) + )) + else: + actions.append(( + "Close IRSG Ballot", + urlreverse('ietf.doc.views_ballot.close_irsg_ballot', kwargs=dict(name=doc.name)) + )) + elif doc.stream_id == "editorial" and has_role(request.user, ("Secretariat", "RSAB Chair")): + if not doc.ballot_open('rsab-approve'): + actions.append(( + "Issue RSAB Ballot", + urlreverse('ietf.doc.views_ballot.issue_rsab_ballot', kwargs=dict(name=doc.name)) + )) + else: + actions.append(( + "Close RSAB Ballot", + urlreverse('ietf.doc.views_ballot.close_rsab_ballot', kwargs=dict(name=doc.name)) + )) if (doc.get_state_slug() not in ["rfc", "expired"] and doc.stream_id in ("ise", "irtf") and has_role(request.user, ("Secretariat", "IRTF Chair")) and not conflict_reviews and not snapshot): @@ -404,15 +434,15 @@ def document_main(request, name, rev=None, document_html=False): label += " (note that intended status is not set)" actions.append((label, urlreverse('ietf.doc.views_conflict_review.start_review', kwargs=dict(name=doc.name)))) - if (doc.get_state_slug() not in ["rfc", "expired"] and doc.stream_id in ("iab", "ise", "irtf") - and (has_role(request.user, ("Secretariat", "IRTF Chair")) if doc.stream_id=="irtf" else can_edit_stream_info) and not snapshot): - if doc.get_state_slug('draft-stream-%s' % doc.stream_id) not in ('rfc-edit', 'pub', 'dead'): - label = "Request Publication" - if not doc.intended_std_level: - label += " (note that intended status is not set)" - if iesg_state and iesg_state_slug not in ('idexists','dead'): - label += " (Warning: the IESG state indicates ongoing IESG processing)" - actions.append((label, urlreverse('ietf.doc.views_draft.request_publication', kwargs=dict(name=doc.name)))) + if doc.get_state_slug() not in ["rfc", "expired"] and not snapshot: + if can_request_rfc_publication(request.user, doc): + if doc.get_state_slug('draft-stream-%s' % doc.stream_id) not in ('rfc-edit', 'pub', 'dead'): + label = "Request Publication" + if not doc.intended_std_level: + label += " (note that intended status is not set)" + if iesg_state and iesg_state_slug not in ('idexists','dead'): + label += " (Warning: the IESG state indicates ongoing IESG processing)" + actions.append((label, urlreverse('ietf.doc.views_draft.request_publication', kwargs=dict(name=doc.name)))) if doc.get_state_slug() not in ["rfc", "expired"] and doc.stream_id in ("ietf",) and not snapshot: if iesg_state_slug == 'idexists' and can_edit: @@ -491,8 +521,6 @@ def document_main(request, name, rev=None, document_html=False): draft_name=draft_name, telechat=telechat, iesg_ballot_summary=iesg_ballot_summary, - # PEY: Currently not using irsg_ballot_summary in the template, but it should be. That will take a new box for IRSG data. - irsg_ballot_summary=irsg_ballot_summary, submission=submission, resurrected_by=resurrected_by, @@ -1315,6 +1343,28 @@ def document_irsg_ballot(request, name, ballot_id=None): # ballot_type_slug=ballot.ballot_type.slug, )) +def document_rsab_ballot(request, name, ballot_id=None): + doc = get_object_or_404(Document, docalias__name=name) + top = render_document_top(request, doc, "rsabballot", name) + if not ballot_id: + ballot = doc.latest_event(BallotDocEvent, type="created_ballot", ballot_type__slug='rsab-approve') + if ballot: + ballot_id = ballot.id + + c = document_ballot_content(request, doc, ballot_id, editable=True) + + request.session['ballot_edit_return_point'] = request.path_info + + return render( + request, + "doc/document_ballot.html", + dict( + doc=doc, + top=top, + ballot_content=c, + ) + ) + def ballot_popup(request, name, ballot_id): doc = get_object_or_404(Document, docalias__name=name) c = document_ballot_content(request, doc, ballot_id=ballot_id, editable=False) diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index f22ad35488..e389173e41 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -43,7 +43,7 @@ from ietf.group.models import Group, Role, GroupFeatures from ietf.iesg.models import TelechatDate from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream, user_is_person -from ietf.ietfauth.utils import role_required +from ietf.ietfauth.utils import role_required, can_request_rfc_publication from ietf.mailtrigger.utils import gather_address_lists from ietf.message.models import Message from ietf.name.models import IntendedStdLevelName, DocTagName, StreamName @@ -459,8 +459,6 @@ def change_intention(request, name): or is_authorized_in_doc_stream(request.user, doc)): permission_denied(request, "You do not have the necessary permissions to view this page.") - login = request.user.person - if request.method == 'POST': form = ChangeIntentionForm(request.POST) if form.is_valid(): @@ -468,36 +466,7 @@ def change_intention(request, name): comment = form.cleaned_data['comment'].strip() old_level = doc.intended_std_level - if new_level != old_level: - doc.intended_std_level = new_level - - events = [] - e = DocEvent(doc=doc, rev=doc.rev, by=login, type='changed_document') - e.desc = "Intended Status changed to %s from %s"% (new_level,old_level) - e.save() - events.append(e) - - if comment: - c = DocEvent(doc=doc, rev=doc.rev, by=login, type="added_comment") - c.desc = comment - c.save() - events.append(c) - - de = doc.latest_event(ConsensusDocEvent, type="changed_consensus") - prev_consensus = de and de.consensus - if not prev_consensus and doc.intended_std_level_id in ("std", "ds", "ps", "bcp"): - ce = ConsensusDocEvent(doc=doc, rev=doc.rev, by=login, type="changed_consensus") - ce.consensus = True - ce.desc = "Changed consensus to %s from %s" % (nice_consensus(True), - nice_consensus(prev_consensus)) - ce.save() - events.append(ce) - - doc.save_with_history(events) - - msg = "\n".join(e.desc for e in events) - - email_intended_status_changed(request, doc, msg) + set_intended_status_level(request=request, doc=doc, new_level=new_level, old_level=old_level, comment=comment) return HttpResponseRedirect(doc.get_absolute_url()) @@ -1290,14 +1259,11 @@ class PublicationForm(forms.Form): subject = forms.CharField(max_length=200, required=True) body = forms.CharField(widget=forms.Textarea, required=True, strip=False) - doc = get_object_or_404(Document, type="draft", name=name, stream__in=("iab", "ise", "irtf")) + doc = get_object_or_404(Document, type="draft", name=name, stream__in=("iab", "ise", "irtf", "editorial")) - if doc.stream_id == "irtf": - if not has_role(request.user, ("Secretariat", "IRTF Chair")): - permission_denied(request, "You do not have the necessary permissions to view this page.") - elif not is_authorized_in_doc_stream(request.user, doc): + if not can_request_rfc_publication(request.user, doc): permission_denied(request, "You do not have the necessary permissions to view this page.") - + consensus_event = doc.latest_event(ConsensusDocEvent, type="changed_consensus") m = Message() @@ -1378,63 +1344,163 @@ class PublicationForm(forms.Form): ) class AdoptDraftForm(forms.Form): - group = forms.ModelChoiceField(queryset=Group.objects.filter(type__features__acts_like_wg=True, state="active").order_by("-type", "acronym"), required=True, empty_label=None) - newstate = forms.ModelChoiceField(queryset=State.objects.filter(type__in=['draft-stream-ietf','draft-stream-irtf'], used=True).exclude(slug__in=settings.GROUP_STATES_WITH_EXTRA_PROCESSING), required=True, label="State") - comment = forms.CharField(widget=forms.Textarea, required=False, label="Comment", help_text="Optional comment explaining the reasons for the adoption.", strip=False) + group = forms.ModelChoiceField( + queryset=Group.objects.filter(type__features__acts_like_wg=True, state="active") + .order_by("-type", "acronym") + .distinct(), + required=True, + empty_label=None, + ) + newstate = forms.ModelChoiceField( + queryset=State.objects.filter( + type__in=[ + "draft-stream-ietf", + "draft-stream-irtf", + "draft-stream-editorial", + ], + used=True, + ).exclude(slug__in=settings.GROUP_STATES_WITH_EXTRA_PROCESSING), + required=True, + label="State", + ) + comment = forms.CharField( + widget=forms.Textarea, + required=False, + label="Comment", + help_text="Optional comment explaining the reasons for the adoption.", + strip=False, + ) weeks = forms.IntegerField(required=False, label="Expected weeks in adoption state") def __init__(self, *args, **kwargs): user = kwargs.pop("user") - rg_features = GroupFeatures.objects.get(type_id='rg') - wg_features = GroupFeatures.objects.get(type_id='wg') - super(AdoptDraftForm, self).__init__(*args, **kwargs) + docman_roles = {} + for group_type in ("wg", "ag", "rg", "rag", "edwg"): + docman_roles[group_type] = GroupFeatures.objects.get( + type_id=group_type + ).docman_roles + state_types = set() if has_role(user, "Secretariat"): - state_types.update(['draft-stream-ietf','draft-stream-irtf']) - else: - if (has_role(user, "IRTF Chair") - or Group.objects.filter(type="rg", - state="active", - role__person__user=user, - role__name__in=rg_features.docman_roles).exists()): - state_types.add('draft-stream-irtf') - if Group.objects.filter( type="wg", - state="active", - role__person__user=user, - role__name__in=wg_features.docman_roles).exists(): - state_types.add('draft-stream-ietf') - - state_choices = State.objects.filter(type__in=state_types, used=True).exclude(slug__in=settings.GROUP_STATES_WITH_EXTRA_PROCESSING) + state_types.update( + ["draft-stream-ietf", "draft-stream-irtf", "draft-stream-editorial"] + ) + else: + if has_role(user, "IRTF Chair") or any( + [ + Group.objects.filter( + type=type_id, + state="active", + role__person__user=user, + role__name__in=docman_roles[type_id], + ).exists() + for type_id in ("rg", "rag") + ] + ): + state_types.add("draft-stream-irtf") + if any( + [ + Group.objects.filter( + type=type_id, + state="active", + role__person__user=user, + role__name__in=docman_roles[type_id], + ).exists() + for type_id in ("wg", "ag") + ] + ): + state_types.add("draft-stream-ietf") + if Group.objects.filter( + type="edwg", + state="active", + role__person__user=user, + role__name__in=docman_roles["edwg"], + ).exists(): + state_types.add("draft-stream-editorial") + + state_choices = State.objects.filter(type__in=state_types, used=True).exclude( + slug__in=settings.GROUP_STATES_WITH_EXTRA_PROCESSING + ) if not has_role(user, "Secretariat"): + allow_matching_groups = [] if has_role(user, "IRTF Chair"): - group_queryset = self.fields["group"].queryset.filter(Q(role__person__user=user, role__name__in=rg_features.docman_roles)|Q(type="rg", state="active")).distinct() - else: - group_queryset = self.fields["group"].queryset.filter(role__person__user=user, role__name__in=wg_features.docman_roles).distinct() - self.fields["group"].queryset = group_queryset - - self.fields['group'].choices = [(g.pk, '%s - %s' % (g.acronym, g.name)) for g in self.fields["group"].queryset] - self.fields['newstate'].choices = [('','-- Pick a state --')] - self.fields['newstate'].choices.extend([(x.pk,x.name + " (IETF)") for x in state_choices if x.type_id == 'draft-stream-ietf']) - self.fields['newstate'].choices.extend([(x.pk,x.name + " (IRTF)") for x in state_choices if x.type_id == 'draft-stream-irtf']) + allow_matching_groups.append(Q(type__in=["rg", "rag"])) + for type_id in docman_roles: + allow_matching_groups.append( + Q( + role__person__user=user, + role__name__in=docman_roles[type_id], + type_id=type_id, + ) + ) + combined_query = Q(pk__in=[]) # Never use Q() here when following this pattern + for query in allow_matching_groups: + combined_query |= query + + self.fields["group"].queryset = self.fields["group"].queryset.filter(combined_query) + + self.fields["group"].choices = [ + (g.pk, "%s - %s" % (g.acronym, g.name)) + for g in self.fields["group"].queryset + ] + self.fields["newstate"].choices = [("", "-- Pick a state --")] + self.fields["newstate"].choices.extend( + [ + (x.pk, x.name + " (IETF)") + for x in state_choices + if x.type_id == "draft-stream-ietf" + ] + ) + self.fields["newstate"].choices.extend( + [ + (x.pk, x.name + " (IRTF)") + for x in state_choices + if x.type_id == "draft-stream-irtf" + ] + ) + self.fields["newstate"].choices.extend( + [ + (x.pk, x.name + " (Editorial)") + for x in state_choices + if x.type_id == "draft-stream-editorial" + ] + ) def clean_newstate(self): - group = self.cleaned_data['group'] - newstate = self.cleaned_data['newstate'] - - if (newstate.type_id == 'draft-stream-ietf') and (group.type_id == 'rg'): - raise forms.ValidationError('Cannot assign IETF WG state to IRTF group') - elif (newstate.type_id == 'draft-stream-irtf') and (group.type_id == 'wg'): - raise forms.ValidationError('Cannot assign IRTF RG state to IETF group') - else: - return newstate - + group = self.cleaned_data["group"] + newstate = self.cleaned_data["newstate"] + + ok_to_assign = ( + ("draft-stream-ietf", ("wg", "ag")), + ("draft-stream-irtf", ("rg", "rag")), + ("draft-stream-editorial", ("edwg",)), + ) + ok = True + for stream, types in ok_to_assign: + if newstate.type_id == stream and group.type_id not in types: + ok = False + break + if not ok: + state_type_text = newstate.type_id.split("-")[-1].upper() + group_type_text = { + "wg": "IETF Working Group", + "ag": "IETF Area Group", + "rg": "IRTF Research Group", + "rag": "IRTF Area Group", + "edwg": "Editorial Stream Working Group", + }[group.type_id] + raise forms.ValidationError( + f"Cannot assign {state_type_text} state to a {group_type_text}" + ) + return newstate + + @login_required def adopt_draft(request, name): doc = get_object_or_404(Document, type="draft", name=name) - if not can_adopt_draft(request.user, doc): permission_denied(request, "You don't have permission to access this page.") @@ -1447,8 +1513,10 @@ def adopt_draft(request, name): events = [] group = form.cleaned_data["group"] - if group.type.slug == "rg": - new_stream = StreamName.objects.get(slug="irtf") + if group.type.slug in ("rg", "rag"): + new_stream = StreamName.objects.get(slug="irtf") + elif group.type.slug =="edwg": + new_stream = StreamName.objects.get(slug="editorial") else: new_stream = StreamName.objects.get(slug="ietf") @@ -1467,6 +1535,13 @@ def adopt_draft(request, name): if old_stream != None: email_stream_changed(request, doc, old_stream, new_stream) + # Force intended std level here if stream isn't ietf + if new_stream.slug != "ietf": + old_level = doc.intended_std_level + new_level = IntendedStdLevelName.objects.get(slug="inf", used=True) + set_intended_status_level(request=request, doc=doc, new_level=new_level, old_level=old_level, comment="") + + # group if group != doc.group: e = DocEvent(type="changed_group", doc=doc, rev=doc.rev, by=by) @@ -1737,3 +1812,36 @@ def change_stream_state(request, name, state_type): "state_type": state_type, "next_states": next_states, }) + +# This should be in ietf.doc.utils, but placing it there brings a circular import issue with ietf.doc.mail +def set_intended_status_level(request, doc, new_level, old_level, comment): + if new_level != old_level: + doc.intended_std_level = new_level + + events = [] + e = DocEvent(doc=doc, rev=doc.rev, by=request.user.person, type='changed_document') + e.desc = "Intended Status changed to %s from %s"% (new_level,old_level) + e.save() + events.append(e) + + if comment: + c = DocEvent(doc=doc, rev=doc.rev, by=request.user.person, type="added_comment") + c.desc = comment + c.save() + events.append(c) + + de = doc.latest_event(ConsensusDocEvent, type="changed_consensus") + prev_consensus = de and de.consensus + if not prev_consensus and doc.intended_std_level_id in ("std", "ds", "ps", "bcp"): + ce = ConsensusDocEvent(doc=doc, rev=doc.rev, by=request.user.person, type="changed_consensus") + ce.consensus = True + ce.desc = "Changed consensus to %s from %s" % (nice_consensus(True), + nice_consensus(prev_consensus)) + ce.save() + events.append(ce) + + doc.save_with_history(events) + + msg = "\n".join(e.desc for e in events) + + email_intended_status_changed(request, doc, msg) diff --git a/ietf/group/migrations/0060_editoral_refactor.py b/ietf/group/migrations/0060_editoral_refactor.py new file mode 100755 index 0000000000..d6bf1abea0 --- /dev/null +++ b/ietf/group/migrations/0060_editoral_refactor.py @@ -0,0 +1,152 @@ +# Copyright The IETF Trust 2023, All Rights Reserved + +from django.db import migrations + + +def forward(apps, schema_editor): + Group = apps.get_model("group", "Group") + GroupFeatures = apps.get_model("group", "GroupFeatures") + GroupTypeName = apps.get_model("name", "GroupTypeName") + + GroupTypeName.objects.create( + slug="edwg", + name="Editorial Stream Working Group", + desc="Editorial Stream Working Group", + used=True, + ) + GroupTypeName.objects.create( + slug="edappr", + name="Editorial Stream Approval Group", + desc="Editorial Stream Approval Group", + used=True, + ) + Group.objects.filter(acronym="rswg").update(type_id="edwg") + Group.objects.filter(acronym="rsab").update(type_id="edappr") + Group.objects.filter(acronym="editorial").delete() + GroupFeatures.objects.create( + type_id="edwg", + need_parent=False, + has_milestones=False, + has_chartering_process=False, + has_documents=True, + has_session_materials=True, + has_meetings=True, + has_reviews=False, + has_default_chat=True, + acts_like_wg=True, + create_wiki=False, + custom_group_roles=False, + customize_workflow=True, + is_schedulable=True, + show_on_agenda=True, + agenda_filter_type_id="normal", + req_subm_approval=True, + agenda_type_id="ietf", + about_page="ietf.group.views.group_about", + default_tab="ietf.group.views.group_documents", + material_types=["slides"], + default_used_roles=["chair"], + admin_roles=["chair"], + docman_roles=["chair"], + groupman_roles=["chair"], + groupman_authroles=["Secretariat"], + matman_roles=["chair"], + role_order=["chair"], + session_purposes=["regular"], + ) + # Create edappr GroupFeature + GroupFeatures.objects.create( + type_id="edappr", + need_parent=False, + has_milestones=False, + has_chartering_process=False, + has_documents=False, + has_session_materials=True, + has_meetings=True, + has_reviews=False, + has_default_chat=True, + acts_like_wg=False, + create_wiki=False, + custom_group_roles=False, + customize_workflow=False, + is_schedulable=True, + show_on_agenda=True, + agenda_filter_type_id="normal", + req_subm_approval=False, + agenda_type_id="ietf", + about_page="ietf.group.views.group_about", + default_tab="ietf.group.views.group_about", + material_types=["slides"], + default_used_roles=["chair", "member"], + admin_roles=["chair"], + docman_roles=["chair"], + groupman_roles=["chair"], + groupman_authroles=["Secretariat"], + matman_roles=["chair"], + role_order=["chair", "member"], + session_purposes=["officehourse", "regular"], + ) + GroupFeatures.objects.filter(type_id="editorial").delete() + GroupTypeName.objects.filter(slug="editorial").delete() + + +def reverse(apps, schema_editor): + Group = apps.get_model("group", "Group") + GroupFeatures = apps.get_model("group", "GroupFeatures") + GroupTypeName = apps.get_model("name", "GroupTypeName") + GroupTypeName.objects.filter(slug="editorial").update(name="Editorial") + Group.objects.create( + acronym="editorial", + name="Editorial Stream", + state_id="active", + type_id="editorial", + parent=None, + ) + GroupFeatures.objects.create( + type_id="editorial", + need_parent=False, + has_milestones=False, + has_chartering_process=False, + has_documents=False, + has_session_materials=False, + has_meetings=False, + has_reviews=False, + has_default_chat=False, + acts_like_wg=False, + create_wiki=False, + custom_group_roles=True, + customize_workflow=False, + is_schedulable=False, + show_on_agenda=False, + agenda_filter_type_id="none", + req_subm_approval=False, + agenda_type_id="side", + about_page="ietf.group.views.group_about", + default_tab="ietf.group.views.group_about", + material_types=["slides"], + default_used_roles=["auth", "chair"], + admin_roles=["chair"], + docman_roles=[], + groupman_roles=[], + matman_roles=[], + role_order=["chair", "secr"], + session_purposes=["officehours"], + ) + Group.objects.filter(acronym__in=["rswg", "rsab"]).update(type_id="rfcedtyp") + GroupTypeName.objects.create( + slug="editorial", + name="Editorial", + desc="Editorial Stream Group", + used=True, + ) + GroupFeatures.objects.filter(type_id__in=["edwg", "edappr"]).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("group", "0059_use_timezone_now_for_group_models"), + ("name", "0045_polls_and_chatlogs"), + ] + + operations = [migrations.RunPython(forward, reverse)] diff --git a/ietf/group/tests_info.py b/ietf/group/tests_info.py index 98af69ba07..ac2bd182a8 100644 --- a/ietf/group/tests_info.py +++ b/ietf/group/tests_info.py @@ -87,7 +87,7 @@ def test_active_groups(self): self.assertContains(r, "Directorate") self.assertContains(r, "AG") - for slug in GroupTypeName.objects.exclude(slug__in=['wg','rg','ag','rag','area','dir','review','team','program','adhoc','ise','adm','iabasg','rfcedtyp']).values_list('slug',flat=True): + for slug in GroupTypeName.objects.exclude(slug__in=['wg','rg','ag','rag','area','dir','review','team','program','adhoc','ise','adm','iabasg','rfcedtyp', 'edwg', 'edappr']).values_list('slug',flat=True): with self.assertRaises(NoReverseMatch): url=urlreverse('ietf.group.views.active_groups', kwargs=dict(group_type=slug)) @@ -1826,7 +1826,7 @@ def ensure_cant_edit(group,user): self.assertEqual(response.status_code, 404) self.client.logout() - for type_id in GroupTypeName.objects.exclude(slug__in=('wg','rg','ag','rag','team')).values_list('slug',flat=True): + for type_id in GroupTypeName.objects.exclude(slug__in=('wg','rg','ag','rag','team','edwg')).values_list('slug',flat=True): group = GroupFactory.create(type_id=type_id) for user in (None,User.objects.get(username='secretary')): ensure_updates_dont_show(group,user) diff --git a/ietf/group/views.py b/ietf/group/views.py index 9f43028236..16e7bb55ef 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -334,7 +334,7 @@ def active_adm(request): return render(request, 'group/active_adm.html', {'adm' : adm }) def active_rfced(request): - rfced = Group.objects.filter(type="rfcedtyp", state="active").order_by("parent", "name") + rfced = Group.objects.filter(type__in=["rfcedtyp", "edwg", "edappr"], state="active").order_by("parent", "name") return render(request, 'group/active_rfced.html', {'rfced' : rfced}) diff --git a/ietf/ietfauth/utils.py b/ietf/ietfauth/utils.py index 43353ca533..ffda3acffd 100644 --- a/ietf/ietfauth/utils.py +++ b/ietf/ietfauth/utils.py @@ -67,6 +67,7 @@ def has_role(user, role_names, *args, **kwargs): "IETF Chair": Q(person=person, name="chair", group__acronym="ietf"), "IETF Trust Chair": Q(person=person, name="chair", group__acronym="ietf-trust"), "IRTF Chair": Q(person=person, name="chair", group__acronym="irtf"), + "RSAB Chair": Q(person=person, name="chair", group__acronym="rsab"), "IAB Chair": Q(person=person, name="chair", group__acronym="iab"), "IAB Executive Director": Q(person=person, name="execdir", group__acronym="iab"), "IAB Group Chair": Q(person=person, name="chair", group__type="iab", group__state="active"), @@ -90,6 +91,7 @@ def has_role(user, role_names, *args, **kwargs): "Reviewer": Q(person=person, name="reviewer", group__state="active"), "Review Team Secretary": Q(person=person, name="secr", group__reviewteamsettings__isnull=False,group__state="active", ), "IRSG Member": (Q(person=person, name="member", group__acronym="irsg") | Q(person=person, name="chair", group__acronym="irtf") | Q(person=person, name="atlarge", group__acronym="irsg")), + "RSAB Member": Q(person=person, name="member", group__acronym="rsab"), "Robot": Q(person=person, name="robot", group__acronym="secretariat"), } @@ -163,6 +165,10 @@ def is_authorized_in_doc_stream(user, doc): if doc.group.type.slug == 'individ': docman_roles = GroupFeatures.objects.get(type_id="ietf").docman_roles group_req = Q(group__acronym=doc.stream.slug) + elif doc.stream.slug == "editorial": + group_req = Q(group=doc.group) | Q(group__acronym='rsab') + if doc.group.type.slug in ("individ", "rfcedtype"): + docman_roles = GroupFeatures.objects.get(type_id="rfcedtyp").docman_roles else: group_req = Q() # no group constraint for other cases @@ -295,3 +301,24 @@ def scope_registration(self): return info +def can_request_rfc_publication(user, doc): + """Answers whether this user has an appropriate role to send this document to the RFC Editor for publication as an RFC. + + This not take anything but the stream of the document into account. + + NOTE: This intentionally always returns False for IETF stream documents. + The publication request process for the IETF stream is handled by the + secretariat at ietf.doc.views_ballot.approve_ballot""" + + if doc.stream_id == "irtf": + return has_role(user, ("Secretariat", "IRTF Chair")) + elif doc.stream_id == "editorial": + return has_role(user, ("Secretariat", "RSAB Chair")) + elif doc.stream_id == "ise": + return has_role(user, ("Secretariat", "ISE")) + elif doc.stream_id == "iab": + return has_role(user, ("Secretariat", "IAB Chair")) + elif doc.stream_id == "ietf": + return False # See the docstring + else: + return False diff --git a/ietf/mailtrigger/migrations/0024_rsab_ballots.py b/ietf/mailtrigger/migrations/0024_rsab_ballots.py new file mode 100644 index 0000000000..1db69a9a2f --- /dev/null +++ b/ietf/mailtrigger/migrations/0024_rsab_ballots.py @@ -0,0 +1,72 @@ +# Copyright The IETF Trust 2022, All Rights Reserved# Generated by Django 2.2.28 on 2022-12-22 22:41 + +from django.db import migrations + + +def forward(apps, schema_editor): + Recipient = apps.get_model("mailtrigger", "Recipient") + MailTrigger = apps.get_model("mailtrigger", "MailTrigger") + + rsab = Recipient.objects.create( + slug="rsab", + desc="The RFC Series Approval Board", + template="The RSAB ", + ) + + rsab_ballot_saved = MailTrigger.objects.create( + slug="rsab_ballot_saved", + desc="Recipients when a new RSAB ballot position with comments is saved", + ) + rsab_ballot_saved.to.add(rsab) + rsab_ballot_saved.cc.set( + Recipient.objects.filter( + slug__in=[ + "doc_affecteddoc_authors", + "doc_affecteddoc_group_chairs", + "doc_affecteddoc_notify", + "doc_authors", + "doc_group_chairs", + "doc_group_mail_list", + "doc_notify", + "doc_shepherd", + ] + ) + ) + + rsab_ballot_issued = MailTrigger.objects.create( + slug="rsab_ballot_issued", + desc="Recipients when a new RSAB ballot is issued", + ) + rsab_ballot_issued.to.add(rsab) + rsab_ballot_issued.cc.set( + Recipient.objects.filter( + slug__in=[ + "doc_affecteddoc_authors", + "doc_affecteddoc_group_chairs", + "doc_affecteddoc_notify", + "doc_authors", + "doc_group_chairs", + "doc_group_mail_list", + "doc_notify", + "doc_shepherd", + ] + ) + ) + + +def reverse(apps, schema_editor): + Recipient = apps.get_model("mailtrigger", "Recipient") + MailTrigger = apps.get_model("mailtrigger", "MailTrigger") + MailTrigger.objects.filter( + slug__in=("rsab_ballot_issued", "rsab_ballot_saved") + ).delete() + Recipient.objects.filter(slug="rsab").delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("mailtrigger", "0023_bofreq_triggers"), + ] + + operations = [migrations.RunPython(forward, reverse)] diff --git a/ietf/mailtrigger/models.py b/ietf/mailtrigger/models.py index a1b712c574..171dbd85ed 100644 --- a/ietf/mailtrigger/models.py +++ b/ietf/mailtrigger/models.py @@ -9,7 +9,7 @@ from ietf.doc.utils_bofreq import bofreq_editors, bofreq_responsible from ietf.utils.mail import formataddr, get_email_addresses_from_text -from ietf.group.models import Group +from ietf.group.models import Group, Role from ietf.person.models import Email, Alias from ietf.review.models import ReviewTeamSettings @@ -137,10 +137,13 @@ def gather_group_steering_group(self,**kwargs): def gather_stream_managers(self, **kwargs): addrs = [] - manager_map = dict(ise = '', - irtf = '', - ietf = '', - iab = '') + manager_map = dict( + ise = '', + irtf = '', + ietf = '', + iab = '', + editorial = Role.objects.filter(group__acronym="rsab",name_id="chair").values_list("email__address", flat=True), + ) if 'streams' in kwargs: for stream in kwargs['streams']: if stream in manager_map: diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 38a051b913..4ababfc9ca 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -6364,7 +6364,7 @@ def test_meeting_requests(self): # a couple non-wg group types, confirm that their has_meetings features are as expected group_type_with_meetings = 'adhoc' self.assertTrue(GroupFeatures.objects.get(pk=group_type_with_meetings).has_meetings) - group_type_without_meetings = 'editorial' + group_type_without_meetings = 'sdo' self.assertFalse(GroupFeatures.objects.get(pk=group_type_without_meetings).has_meetings) area = GroupFactory(type_id='area', acronym='area') diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index 2f0523cea6..be5767289a 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -147,6 +147,23 @@ "model": "doc.ballottype", "pk": 7 }, + { + "fields": { + "doc_type": "draft", + "name": "RSAB Approve", + "order": 0, + "positions": [ + "concern", + "yes", + "recuse" + ], + "question": "Is this draft ready for publication in the Editorial stream?", + "slug": "rsab-approve", + "used": true + }, + "model": "doc.ballottype", + "pk": 8 + }, { "fields": { "desc": "", @@ -2457,6 +2474,71 @@ "model": "doc.state", "pk": 168 }, + { + "fields": { + "desc": "", + "name": "Replaced editorial stream document", + "next_states": [], + "order": 0, + "slug": "repl", + "type": "draft-stream-editorial", + "used": true + }, + "model": "doc.state", + "pk": 170 + }, + { + "fields": { + "desc": "", + "name": "Active editorial stream document", + "next_states": [], + "order": 2, + "slug": "active", + "type": "draft-stream-editorial", + "used": true + }, + "model": "doc.state", + "pk": 171 + }, + { + "fields": { + "desc": "", + "name": "Editorial stream document under RSAB review", + "next_states": [], + "order": 3, + "slug": "rsabpoll", + "type": "draft-stream-editorial", + "used": true + }, + "model": "doc.state", + "pk": 172 + }, + { + "fields": { + "desc": "", + "name": "Published RFC", + "next_states": [], + "order": 4, + "slug": "pub", + "type": "draft-stream-editorial", + "used": true + }, + "model": "doc.state", + "pk": 173 + }, + { + "fields": { + "desc": "", + "name": "Dead editorial stream document", + "next_states": [], + "order": 5, + "slug": "dead", + "type": "draft-stream-editorial", + "used": true + }, + "model": "doc.state", + "pk": 174 + }, { "fields": { "label": "State" @@ -2548,6 +2630,13 @@ "model": "doc.statetype", "pk": "draft-rfceditor" }, + { + "fields": { + "label": "Editorial stream state" + }, + "model": "doc.statetype", + "pk": "draft-stream-editorial" + }, { "fields": { "label": "IAB state" @@ -2852,37 +2941,74 @@ "about_page": "ietf.group.views.group_about", "acts_like_wg": false, "admin_roles": "[\n \"chair\"\n]", - "agenda_filter_type": "none", - "agenda_type": "side", + "agenda_filter_type": "normal", + "agenda_type": "ietf", "create_wiki": false, - "custom_group_roles": true, + "custom_group_roles": false, "customize_workflow": false, "default_parent": "", "default_tab": "ietf.group.views.group_about", - "default_used_roles": "[\n \"auth\",\n \"chair\"\n]", - "docman_roles": "[]", + "default_used_roles": "[\n \"chair\",\n \"member\"\n]", + "docman_roles": "[\n \"chair\"\n]", "groupman_authroles": "[\n \"Secretariat\"\n]", - "groupman_roles": "[]", + "groupman_roles": "[\n \"chair\"\n]", "has_chartering_process": false, - "has_default_chat": false, + "has_default_chat": true, "has_documents": false, - "has_meetings": false, + "has_meetings": true, "has_milestones": false, "has_nonsession_materials": false, "has_reviews": false, - "has_session_materials": false, - "is_schedulable": false, + "has_session_materials": true, + "is_schedulable": true, "material_types": "[\n \"slides\"\n]", - "matman_roles": "[]", + "matman_roles": "[\n \"chair\"\n]", + "need_parent": false, + "parent_types": [], + "req_subm_approval": false, + "role_order": "[\n \"chair\",\n \"member\"\n]", + "session_purposes": "[\n \"officehourse\",\n \"regular\"\n]", + "show_on_agenda": true + }, + "model": "group.groupfeatures", + "pk": "edappr" + }, + { + "fields": { + "about_page": "ietf.group.views.group_about", + "acts_like_wg": true, + "admin_roles": "[\n \"chair\"\n]", + "agenda_filter_type": "normal", + "agenda_type": "ietf", + "create_wiki": false, + "custom_group_roles": false, + "customize_workflow": true, + "default_parent": "", + "default_tab": "ietf.group.views.group_documents", + "default_used_roles": "[\n \"chair\"\n]", + "docman_roles": "[\n \"chair\"\n]", + "groupman_authroles": "[\n \"Secretariat\"\n]", + "groupman_roles": "[\n \"chair\"\n]", + "has_chartering_process": false, + "has_default_chat": true, + "has_documents": true, + "has_meetings": true, + "has_milestones": false, + "has_nonsession_materials": false, + "has_reviews": false, + "has_session_materials": true, + "is_schedulable": true, + "material_types": "[\n \"slides\"\n]", + "matman_roles": "[\n \"chair\"\n]", "need_parent": false, "parent_types": [], "req_subm_approval": true, - "role_order": "[\n \"chair\",\n \"secr\"\n]", - "session_purposes": "[\n \"officehours\"\n]", - "show_on_agenda": false + "role_order": "[\n \"chair\"\n]", + "session_purposes": "[\n \"regular\"\n]", + "show_on_agenda": true }, "model": "group.groupfeatures", - "pk": "editorial" + "pk": "edwg" }, { "fields": { @@ -3398,7 +3524,7 @@ "default_parent": "", "default_tab": "ietf.group.views.group_about", "default_used_roles": "[\n \"auth\",\n \"chair\"\n]", - "docman_roles": "[]", + "docman_roles": "[\n \"chair\"\n]", "groupman_authroles": "[\n \"Secretariat\"\n]", "groupman_roles": "[\n \"chair\"\n]", "has_chartering_process": false, @@ -5268,6 +5394,46 @@ "model": "mailtrigger.mailtrigger", "pk": "review_req_changed" }, + { + "fields": { + "cc": [ + "doc_affecteddoc_authors", + "doc_affecteddoc_group_chairs", + "doc_affecteddoc_notify", + "doc_authors", + "doc_group_chairs", + "doc_group_mail_list", + "doc_notify", + "doc_shepherd" + ], + "desc": "Recipients when a new RSAB ballot is issued", + "to": [ + "rsab" + ] + }, + "model": "mailtrigger.mailtrigger", + "pk": "rsab_ballot_issued" + }, + { + "fields": { + "cc": [ + "doc_affecteddoc_authors", + "doc_affecteddoc_group_chairs", + "doc_affecteddoc_notify", + "doc_authors", + "doc_group_chairs", + "doc_group_mail_list", + "doc_notify", + "doc_shepherd" + ], + "desc": "Recipients when a new RSAB ballot position with comments is saved", + "to": [ + "rsab" + ] + }, + "model": "mailtrigger.mailtrigger", + "pk": "rsab_ballot_saved" + }, { "fields": { "cc": [ @@ -6129,6 +6295,14 @@ "model": "mailtrigger.recipient", "pk": "rfc_editor_if_doc_in_queue" }, + { + "fields": { + "desc": "The RFC Series Approval Board", + "template": "The RSAB " + }, + "model": "mailtrigger.recipient", + "pk": "rsab" + }, { "fields": { "desc": "The person that requested a meeting slot for a given group", @@ -6391,6 +6565,17 @@ "model": "name.ballotpositionname", "pk": "block" }, + { + "fields": { + "blocking": true, + "desc": "", + "name": "Concern", + "order": 0, + "used": true + }, + "model": "name.ballotpositionname", + "pk": "concern" + }, { "fields": { "blocking": true, @@ -11139,14 +11324,25 @@ }, { "fields": { - "desc": "Editorial Stream Group", - "name": "Editorial", + "desc": "Editorial Stream Approval Group", + "name": "Editorial Stream Approval Group", "order": 0, "used": true, "verbose_name": "" }, "model": "name.grouptypename", - "pk": "editorial" + "pk": "edappr" + }, + { + "fields": { + "desc": "Editorial Stream Working Group", + "name": "Editorial Stream Working Group", + "order": 0, + "used": true, + "verbose_name": "" + }, + "model": "name.grouptypename", + "pk": "edwg" }, { "fields": { @@ -16130,7 +16326,7 @@ "fields": { "command": "xym", "switch": "--version", - "time": "2022-12-14T08:09:37.183Z", + "time": "2023-01-24T08:09:45.071Z", "used": true, "version": "xym 0.6.2" }, @@ -16141,7 +16337,7 @@ "fields": { "command": "pyang", "switch": "--version", - "time": "2022-12-14T08:09:37.496Z", + "time": "2023-01-24T08:09:45.405Z", "used": true, "version": "pyang 2.5.3" }, @@ -16152,7 +16348,7 @@ "fields": { "command": "yanglint", "switch": "--version", - "time": "2022-12-14T08:09:37.549Z", + "time": "2023-01-24T08:09:45.421Z", "used": true, "version": "yanglint SO 1.9.2" }, @@ -16163,9 +16359,9 @@ "fields": { "command": "xml2rfc", "switch": "--version", - "time": "2022-12-14T08:09:38.461Z", + "time": "2023-01-24T08:09:46.384Z", "used": true, - "version": "xml2rfc 3.15.3" + "version": "xml2rfc 3.16.0" }, "model": "utils.versioninfo", "pk": 4 diff --git a/ietf/person/utils.py b/ietf/person/utils.py index a7e3227349..393587e8cf 100755 --- a/ietf/person/utils.py +++ b/ietf/person/utils.py @@ -199,11 +199,13 @@ def determine_merge_order(source,target): return source,target def get_active_balloters(ballot_type): - if (ballot_type.slug != "irsg-approve"): - active_balloters = get_active_ads() + if ballot_type.slug == 'irsg-approve': + return get_active_irsg() + elif ballot_type.slug == 'rsab-approve': + return get_active_rsab() else: - active_balloters = get_active_irsg() - return active_balloters + return get_active_ads() + def get_active_ads(): cache_key = "doc:active_ads" @@ -219,7 +221,15 @@ def get_active_irsg(): if not active_irsg_balloters: active_irsg_balloters = list(Person.objects.filter(role__group__acronym='irsg',role__name__in=['chair','member','atlarge']).distinct()) cache.set(cache_key, active_irsg_balloters) - return active_irsg_balloters + return active_irsg_balloters + +def get_active_rsab(): + cache_key = "doc:active_rsab_balloters" + active_rsab_balloters = cache.get(cache_key) + if not active_rsab_balloters: + active_rsab_balloters = list(Person.objects.filter(role__group__acronym='rsab', role__name="member").distinct()) + cache.set(cache_key, active_rsab_balloters) + return active_rsab_balloters def get_dots(person): roles = person.role_set.filter(group__state_id__in=('active','bof','proposed')) diff --git a/ietf/secr/sreq/tests.py b/ietf/secr/sreq/tests.py index ecda21ef27..c23c9b1bad 100644 --- a/ietf/secr/sreq/tests.py +++ b/ietf/secr/sreq/tests.py @@ -44,7 +44,7 @@ def test_main(self): meeting = MeetingFactory(type_id='ietf', date=date_today()) SessionFactory.create_batch(2, meeting=meeting, status_id='sched') SessionFactory.create_batch(2, meeting=meeting, status_id='disappr') - # An additional unscheduled group comes from make_immutable_base_data + # Several unscheduled groups come from make_immutable_base_data url = reverse('ietf.secr.sreq.views.main') self.client.login(username="secretary", password="secretary+password") r = self.client.get(url) @@ -52,7 +52,7 @@ def test_main(self): sched = r.context['scheduled_groups'] self.assertEqual(len(sched), 2) unsched = r.context['unscheduled_groups'] - self.assertEqual(len(unsched), 11) + self.assertEqual(len(unsched), 12) def test_approve(self): meeting = MeetingFactory(type_id='ietf', date=date_today()) diff --git a/ietf/templates/doc/ballot/ballot_comment_mail.txt b/ietf/templates/doc/ballot/ballot_comment_mail.txt index add55c68ef..8fb709b7f5 100644 --- a/ietf/templates/doc/ballot/ballot_comment_mail.txt +++ b/ietf/templates/doc/ballot/ballot_comment_mail.txt @@ -5,7 +5,7 @@ When responding, please keep the subject line intact and reply to all email addresses included in the To and CC lines. (Feel free to cut this introductory paragraph, however.) -{% if doc.type_id == "draft" and doc.stream_id != "irtf" %} +{% if doc.type_id == "draft" and doc.stream_id == "ietf" %} Please refer to https://www.ietf.org/about/groups/iesg/statements/handling-ballot-positions/ for more information about how to handle DISCUSS and COMMENT positions. {% endif %} diff --git a/ietf/templates/doc/ballot/rsab_ballot_approve.html b/ietf/templates/doc/ballot/rsab_ballot_approve.html new file mode 100644 index 0000000000..43f43eafee --- /dev/null +++ b/ietf/templates/doc/ballot/rsab_ballot_approve.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2022, All Rights Reserved #} +{% load origin %} +{% load static %} +{% load django_bootstrap5 %} +{% block title %}Issue ballot for {{ doc }}?{% endblock %} +{% block content %} + {% origin %} +

+ Issue ballot +
+ {{ doc }} +

+

+ {{ question }} +

+
+ {% csrf_token %} + {# curly percent bootstrap_form approval_text_form curly percent #} + + +
+{% endblock %} diff --git a/ietf/templates/doc/ballot/rsab_ballot_close.html b/ietf/templates/doc/ballot/rsab_ballot_close.html new file mode 100644 index 0000000000..2261c070b5 --- /dev/null +++ b/ietf/templates/doc/ballot/rsab_ballot_close.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2022, All Rights Reserved #} +{% load origin %} +{% load django_bootstrap5 %} +{% block title %}Close ballot for {{ doc }}{% endblock %} +{% block content %} + {% origin %} +

+ Close ballot +
+ {{ doc }} +

+

+ {{ question }} +

+
+ {% csrf_token %} + {# curly percent bootstrap_form approval_text_form curly percent #} + + +
+{% endblock %} diff --git a/ietf/templates/doc/ballot_popup.html b/ietf/templates/doc/ballot_popup.html index 815a4f3958..2a04ffab69 100644 --- a/ietf/templates/doc/ballot_popup.html +++ b/ietf/templates/doc/ballot_popup.html @@ -1,4 +1,4 @@ -{# Copyright The IETF Trust 2015, All Rights Reserved #} +{# Copyright The IETF Trust 2015-2022, All Rights Reserved #} {% load origin %} {% origin %} {% load ietf_filters %} @@ -24,7 +24,7 @@