Merge remote-tracking branch 'origin/main' into feat/postgres

This commit is contained in:
Robert Sparks 2023-01-31 16:57:03 -06:00
commit 2a1602d9bb
No known key found for this signature in database
GPG key ID: 6E2A6A5775F91318
47 changed files with 2189 additions and 328 deletions

View file

@ -22,3 +22,7 @@ body:
options:
- label: I agree to follow the [IETF's Code of Conduct](https://github.com/ietf-tools/.github/blob/main/CODE_OF_CONDUCT.md)
required: true
- type: markdown
attributes:
value: |
If you are having trouble logging into the datatracker, please do not open an issue here. Instead, please send email to support@ietf.org providing your name and username.

View file

@ -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

View file

@ -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 <iesg-secretary@ietf.org>'
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

View file

@ -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),
]

View file

@ -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)]

View file

@ -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 @@ class BallotDocEvent(DocEvent):
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:

View file

@ -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 ballot_icon(context, doc):
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"

View file

@ -25,6 +25,7 @@ import debug # pyflakes:ignore
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")

View file

@ -17,7 +17,8 @@ from django.utils import timezone
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 @@ class DeferUndeferTestCase(TestCase):
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):

View file

@ -11,6 +11,7 @@ from collections import Counter
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 @@ from ietf.doc.models import ( Document, DocReminder, DocEvent,
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 @@ class ReleaseDraftTests(TestCase):
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')
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))
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"
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)
# 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)
# get
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
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'])
# 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)
self.assertFalse(mars.list_email in draft.notify)
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'])
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')
# 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)
self.client.login(username='person',password='person+password')
url = urlreverse('ietf.doc.views_draft.adopt_draft', kwargs=dict(name=draft.name))
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
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())
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())
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.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())
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=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())
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.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())
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]]),
)
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):

View file

@ -446,7 +446,7 @@ class IRSGMemberTests(TestCase):
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)

View file

@ -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"])

View file

@ -96,6 +96,7 @@ urlpatterns = [
url(r'^recent/?$', views_search.recent_drafts),
url(r'^select2search/(?P<model_name>(document|docalias))/(?P<doc_type>draft)/$', 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 @@ urlpatterns = [
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<ballot_id>[0-9]+)/$' % settings.URL_REGEXPS, views_doc.document_ballot),
url(r'^%(name)s/ballot/(?P<ballot_id>[0-9]+)/position/$' % settings.URL_REGEXPS, views_ballot.edit_position),
url(r'^%(name)s/ballot/(?P<ballot_id>[0-9]+)/emailposition/$' % settings.URL_REGEXPS, views_ballot.send_ballot_comment),
@ -161,6 +163,8 @@ urlpatterns = [
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<type>[\w-]+)/$', views_help.state_help),
url(r'^help/relationships/$', views_help.relationship_help),

View file

@ -11,7 +11,7 @@ import os
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 DocAlias, RelatedDocument, RelatedDocHistory, Ballot
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()

View file

@ -27,6 +27,7 @@ from ietf.doc.mails import ( email_ballot_deferred, email_ballot_undeferred,
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.response import permission_denied
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 @@ class EditPositionForm(forms.Form):
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...

View file

@ -70,7 +70,7 @@ from ietf.doc.utils_bofreq import bofreq_editors, bofreq_responsible
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)

View file

@ -43,7 +43,7 @@ from ietf.doc.forms import ExtResourceForm
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 <b>%s</b> 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 <b>%s</b> 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 @@ def request_publication(request, name):
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 @@ def request_publication(request, name):
)
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_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)
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
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["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 <b>%s</b> 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 <b>%s</b> 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)

View file

@ -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)]

View file

@ -87,7 +87,7 @@ class GroupPagesTests(TestCase):
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 @@ class StatusUpdateTests(TestCase):
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)

View file

@ -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})

View file

@ -21,6 +21,7 @@ from ietf.person.models import Person, Email
from ietf.mailinglists.models import Allowlisted
from ietf.utils.text import isascii
class RegistrationForm(forms.Form):
email = forms.EmailField(label="Your email (lowercase)")
@ -193,10 +194,18 @@ class ResetPasswordForm(forms.Form):
username = forms.EmailField(label="Your email (lowercase)")
def clean_username(self):
import ietf.ietfauth.views
"""Verify that the username is valid
In addition to EmailField's checks, verifies that a User matching the username exists.
"""
username = self.cleaned_data["username"]
if not User.objects.filter(username=username).exists():
raise forms.ValidationError(mark_safe("Didn't find a matching account. If you don't have an account yet, you can <a href=\"{}\">create one</a>.".format(urlreverse(ietf.ietfauth.views.create_account))))
raise forms.ValidationError(mark_safe(
"Didn't find a matching account. "
"If you don't have an account yet, you can <a href=\"{}\">create one</a>.".format(
urlreverse('ietf.ietfauth.views.create_account')
)
))
return username

View file

@ -429,12 +429,10 @@ class IetfAuthTests(TestCase):
email = 'someone@example.com'
password = 'foobar'
user = User.objects.create(username=email, email=email)
user = PersonFactory(user__email=email).user
user.set_password(password)
user.save()
p = Person.objects.create(name="Some One", ascii="Some One", user=user)
Email.objects.create(address=user.username, person=p, origin=user.username)
# get
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
@ -512,6 +510,39 @@ class IetfAuthTests(TestCase):
r = self.client.get(confirm_url)
self.assertEqual(r.status_code, 404)
def test_reset_password_without_person(self):
"""No password reset for account without a person"""
url = urlreverse('ietf.ietfauth.views.password_reset')
user = UserFactory()
user.set_password('some password')
user.save()
empty_outbox()
r = self.client.post(url, { 'username': user.username})
self.assertContains(r, 'No known active email addresses', status_code=200)
q = PyQuery(r.content)
self.assertTrue(len(q("form .is-invalid")) > 0)
self.assertEqual(len(outbox), 0)
def test_reset_password_address_handling(self):
"""Reset password links are only sent to known, active addresses"""
url = urlreverse('ietf.ietfauth.views.password_reset')
person = PersonFactory()
person.email_set.update(active=False)
empty_outbox()
r = self.client.post(url, { 'username': person.user.username})
self.assertContains(r, 'No known active email addresses', status_code=200)
q = PyQuery(r.content)
self.assertTrue(len(q("form .is-invalid")) > 0)
self.assertEqual(len(outbox), 0)
active_address = EmailFactory(person=person).address
r = self.client.post(url, {'username': person.user.username})
self.assertNotContains(r, 'No known active email addresses', status_code=200)
self.assertEqual(len(outbox), 1)
to = outbox[0].get('To')
self.assertIn(active_address, to)
self.assertNotIn(person.user.username, to)
def test_review_overview(self):
review_req = ReviewRequestFactory()
assignment = ReviewAssignmentFactory(review_request=review_req,reviewer=EmailFactory(person__user__username='reviewer'))

View file

@ -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 @@ class OidcExtraScopeClaims(oidc_provider.lib.claims.ScopeClaims):
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

View file

@ -413,32 +413,39 @@ def password_reset(request):
if request.method == 'POST':
form = ResetPasswordForm(request.POST)
if form.is_valid():
username = form.cleaned_data['username']
submitted_username = form.cleaned_data['username']
# The form validation checks that a matching User exists. Add the person__isnull check
# because the OneToOne field does not gracefully handle checks for user.person is Null.
# If we don't get a User here, we know it's because there's no related Person.
user = User.objects.filter(username=submitted_username, person__isnull=False).first()
if not (user and user.person.email_set.filter(active=True).exists()):
form.add_error(
'username',
'No known active email addresses are associated with this account. '
'Please contact the secretariat for assistance.',
)
else:
data = {
'username': user.username,
'password': user.password and user.password[-4:],
'last_login': user.last_login.timestamp() if user.last_login else None,
}
auth = django.core.signing.dumps(data, salt="password_reset")
data = { 'username': username }
if User.objects.filter(username=username).exists():
user = User.objects.get(username=username)
data['password'] = user.password and user.password[-4:]
if user.last_login:
data['last_login'] = user.last_login.timestamp()
else:
data['last_login'] = None
auth = django.core.signing.dumps(data, salt="password_reset")
domain = Site.objects.get_current().domain
subject = 'Confirm password reset at %s' % domain
from_email = settings.DEFAULT_FROM_EMAIL
to_email = username # form validation makes sure that this is an email address
send_mail(request, to_email, from_email, subject, 'registration/password_reset_email.txt', {
'domain': domain,
'auth': auth,
'username': username,
'expire': settings.MINUTES_TO_EXPIRE_RESET_PASSWORD_LINK,
})
success = True
domain = Site.objects.get_current().domain
subject = 'Confirm password reset at %s' % domain
from_email = settings.DEFAULT_FROM_EMAIL
# Send email to addresses from the database, NOT to the address from the form.
# This prevents unicode spoofing tricks (https://nvd.nist.gov/vuln/detail/CVE-2019-19844).
to_emails = list(set(email.address for email in user.person.email_set.filter(active=True)))
to_emails.sort()
send_mail(request, to_emails, from_email, subject, 'registration/password_reset_email.txt', {
'domain': domain,
'auth': auth,
'username': submitted_username,
'expire': settings.MINUTES_TO_EXPIRE_RESET_PASSWORD_LINK,
})
success = True
else:
form = ResetPasswordForm()
return render(request, 'registration/password_reset.html', {

View file

@ -710,7 +710,7 @@ Subject: test
post_data = {
'iprdocrel_set-TOTAL_FORMS' : 1,
'iprdocrel_set-INITIAL_FORMS' : 0,
'iprdocrel_set-0-id': disclosure.pk,
'iprdocrel_set-0-id': '',
"iprdocrel_set-0-document": disclosure.docs.first().pk,
"iprdocrel_set-0-revisions": disclosure.docs.first().document.rev,
'holder_legal_name': disclosure.holder_legal_name,

View file

@ -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@rfc-editor.org>",
)
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)]

View file

@ -9,7 +9,7 @@ from email.utils import parseaddr
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 @@ class Recipient(models.Model):
def gather_stream_managers(self, **kwargs):
addrs = []
manager_map = dict(ise = '<rfc-ise@rfc-editor.org>',
irtf = '<irtf-chair@irtf.org>',
ietf = '<iesg@ietf.org>',
iab = '<iab-chair@iab.org>')
manager_map = dict(
ise = '<rfc-ise@rfc-editor.org>',
irtf = '<irtf-chair@irtf.org>',
ietf = '<iesg@ietf.org>',
iab = '<iab-chair@iab.org>',
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:

View file

@ -25,6 +25,8 @@ import debug # pyflakes:ignore
from ietf.person.models import Person
from ietf.meeting import models
from ietf.meeting.helpers import get_person_by_email
from ietf.name.models import SessionPurposeName
# 40 runs of the optimiser for IETF 106 with cycles=160 resulted in 16
# zero-violation invocations, with a mean number of runs of 91 and
@ -72,18 +74,31 @@ class Command(BaseCommand):
'Base schedule for generated schedule, specified as "[owner/]name"'
' (default is no base schedule; owner not required if name is unique)'
))
parser.add_argument('-p', '--purpose',
dest='purposes',
action='append',
choices=[
spn.slug for spn in SessionPurposeName.objects.all()
if 'regular' in spn.timeslot_types # scheduler only works with "regular" timeslots
],
default=None,
help=(
'Limit scheduling to specified purpose '
'(use option multiple times to specify more than one purpose; default is all purposes)'
))
def handle(self, meeting, name, max_cycles, verbosity, base_id, *args, **kwargs):
ScheduleHandler(self.stdout, meeting, name, max_cycles, verbosity, base_id).run()
def handle(self, meeting, name, max_cycles, verbosity, base_id, purposes, *args, **kwargs):
ScheduleHandler(self.stdout, meeting, name, max_cycles, verbosity, base_id, purposes).run()
class ScheduleHandler(object):
def __init__(self, stdout, meeting_number, name=None, max_cycles=OPTIMISER_MAX_CYCLES,
verbosity=1, base_id=None):
verbosity=1, base_id=None, session_purposes=None):
self.stdout = stdout
self.verbosity = verbosity
self.name = name
self.max_cycles = max_cycles
self.session_purposes = session_purposes
if meeting_number:
try:
self.meeting = models.Meeting.objects.get(type="ietf", number=meeting_number)
@ -114,6 +129,10 @@ class ScheduleHandler(object):
msgs.append('Applying schedule {} as base schedule'.format(ScheduleId.from_schedule(self.base_schedule)))
self.stdout.write('\n{}\n\n'.format('\n'.join(msgs)))
self._load_meeting()
if len(self.schedule.sessions) == 0:
raise CommandError('No sessions found to schedule')
if len(self.schedule.timeslots) == 0:
raise CommandError('No timeslots found for schedule')
def run(self):
"""Schedule all sessions"""
@ -194,6 +213,8 @@ class ScheduleHandler(object):
Extra arguments are passed to the Session constructor.
"""
sessions_db = self.meeting.session_set.that_can_be_scheduled().filter(type_id='regular')
if self.session_purposes is not None:
sessions_db = sessions_db.filter(purpose__slug__in=self.session_purposes)
if self.base_schedule is None:
fixed_sessions = models.Session.objects.none()
else:

View file

@ -134,7 +134,7 @@ class EditMeetingScheduleTests(IetfSeleniumTestCase):
self.assertEqual(session_info_container.find_element(By.CSS_SELECTOR, ".other-session .time").text, "not yet scheduled")
# deselect
self.driver.find_element(By.CSS_SELECTOR, '.drop-target').click()
self.driver.find_element(By.CSS_SELECTOR, '.timeslot[data-type="regular"] .drop-target').click()
self.assertEqual(session_info_container.find_elements(By.CSS_SELECTOR, ".title"), [])
self.assertNotIn('other-session-selected', s2b_element.get_attribute('class'))
@ -193,9 +193,9 @@ class EditMeetingScheduleTests(IetfSeleniumTestCase):
# violated due to constraints - both the timeslot and its timeslot label
self.assertTrue(self.driver.find_elements(By.CSS_SELECTOR, '#timeslot{}.would-violate-hint'.format(slot1.pk)))
# Find the timeslot label for slot1 - it's the first timeslot in the first room group
# Find the timeslot label for slot1 - it's the first timeslot in the room group containing room 1
slot1_roomgroup_elt = self.driver.find_element(By.CSS_SELECTOR,
'.day-flow .day:first-child .room-group:nth-child(2)' # count from 2 - first-child is the day label
'.day-flow .day:first-child .room-group[data-rooms="1"]'
)
self.assertTrue(
slot1_roomgroup_elt.find_elements(By.CSS_SELECTOR,

View file

@ -6369,7 +6369,7 @@ class SessionTests(TestCase):
# 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')

View file

@ -659,7 +659,9 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None):
sorted_rooms = sorted(
rooms_with_timeslots,
key=lambda room: (
# First, sort regular session rooms ahead of others - these will usually
# Sort higher capacity rooms first.
-room.capacity if room.capacity is not None else 1, # sort rooms with capacity = None at end
# Sort regular session rooms ahead of others - these will usually
# have more timeslots than other room types.
0 if room_data[room.pk]['timeslot_count'] == max_timeslots else 1,
# Sort rooms with earlier timeslots ahead of later
@ -669,8 +671,6 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None):
# Sort by list of starting time and duration so that groups with identical
# timeslot structure will be neighbors. The grouping algorithm relies on this!
room_data[room.pk]['start_and_duration'],
# Within each group, sort higher capacity rooms first.
-room.capacity if room.capacity is not None else 1, # sort rooms with capacity = None at end
# Finally, sort alphabetically by name
room.name
)

View file

@ -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 <rsab@rfc-editor.org>"
},
"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

View file

@ -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'))

View file

@ -44,7 +44,7 @@ class SessionRequestTestCase(TestCase):
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 @@ class SessionRequestTestCase(TestCase):
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())

View file

@ -647,12 +647,9 @@ $(function () {
function updateTimeSlotDurationViolations() {
timeslots.each(function () {
let total = 0;
jQuery(this).find(".session").each(function () {
total += +jQuery(this).data("duration");
});
jQuery(this).toggleClass("overfull", total > +jQuery(this).data("duration"));
const sessionsInSlot = Array.from(this.getElementsByClassName('session'));
const requiredDuration = Math.max(sessionsInSlot.map(elt => Number(elt.dataset.duration)));
this.classList.toggle('overfull', requiredDuration > Number(this.dataset.duration));
});
}

View file

@ -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 %}

View file

@ -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 %}
<h1>
Issue ballot
<br>
<small class="text-muted">{{ doc }}</small>
</h1>
<p class="mt-3">
{{ question }}
</p>
<form method="post">
{% csrf_token %}
{# curly percent bootstrap_form approval_text_form curly percent #}
<button type="submit" class="btn btn-primary" name="rsab_button" value="Yes">Issue ballot</button>
<button type="submit" class="btn btn-secondary float-end" name="rsab_button" value="No">Back</button>
</form>
{% endblock %}

View file

@ -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 %}
<h1>
Close ballot
<br>
<small class="text-muted">{{ doc }}</small>
</h1>
<p>
{{ question }}
</p>
<form method="post">
{% csrf_token %}
{# curly percent bootstrap_form approval_text_form curly percent #}
<button type="submit" class="btn btn-primary" name="rsab_button" value="Yes">Yes</button>
<button type="submit" class="btn btn-primary" name="rsab_button" value="No">No</button>
</form>
{% endblock %}

View file

@ -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 @@
<div class="modal-body">{{ ballot_content }}</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
{% if editable and user|has_role:"Area Director,Secretariat,IRSG Member" %}
{% if editable and user|has_role:"Area Director,Secretariat,IRSG Member,RSAB Member" %}
{% if user|can_ballot:doc %}
<a class="btn btn-primary"
href="{% url "ietf.doc.views_ballot.edit_position" name=doc.name ballot_id=ballot_id %}">
@ -39,7 +39,7 @@
<a class="btn btn-warning"
href="{% url 'ietf.doc.views_ballot.defer_ballot' name=doc.name %}">Defer ballot</a>
{% endif %}
{% if user|has_role:"Secretariat" and ballot_type_slug != "irsg-approve" %}
{% if user|has_role:"Secretariat" %}
<a class="btn btn-danger"
href="{% url 'ietf.doc.views_ballot.clear_ballot' name=doc.name ballot_type_slug=ballot_type_slug %}">
Clear ballot

View file

@ -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 %}
@ -53,9 +53,9 @@
<b>Ballot question:</b> "{{ ballot.ballot_type.question }}"
</p>
{% endif %}
{% if editable and user|has_role:"Area Director,Secretariat,IRSG Member" %}
{% if editable and user|has_role:"Area Director,Secretariat,IRSG Member,RSAB Member" %}
<a class="btn btn-primary my-3"
href="https://mailarchive.ietf.org/arch/search/?q=subject:{{ doc.name }}+AND+subject:(discuss+OR+comment+OR+review)">
href="https://mailarchive.ietf.org/arch/search/?q=subject:{{ doc.name }}+AND+subject:(discuss+OR+comment+OR+review+OR+concern)">
Search Mailarchive
</a>
{% if user|can_ballot:doc %}
@ -64,22 +64,22 @@
Edit position
</a>
{% endif %}
{% if doc.type_id == "draft" or doc.type_id == "conflrev" or doc.type_id == "statchg" %}
{% if user|can_defer:doc %}
{% if deferred %}
<a class="btn btn-warning"
href="{% url 'ietf.doc.views_ballot.undefer_ballot' name=doc.name %}">Undefer ballot</a>
href="{% url 'ietf.doc.views_ballot.undefer_ballot' name=doc.name %}">Undefer ballot</a>
{% else %}
{% if doc.telechat_date %}
<a class="btn btn-warning"
href="{% url 'ietf.doc.views_ballot.defer_ballot' name=doc.name %}">Defer ballot</a>
href="{% url 'ietf.doc.views_ballot.defer_ballot' name=doc.name %}">Defer ballot</a>
{% endif %}
{% endif %}
{% if user|has_role:"Area Director,Secretariat" and ballot.ballot_type.slug != "irsg-approve" %}
{% endif %}
{% if user|can_clear_ballot:doc %}
<a class="btn btn-danger"
href="{% url 'ietf.doc.views_ballot.clear_ballot' name=doc.name ballot_type_slug=ballot.ballot_type.slug %}">
href="{% url 'ietf.doc.views_ballot.clear_ballot' name=doc.name ballot_type_slug=ballot.ballot_type.slug %}">
Clear ballot
</a>
{% endif %}
{% endif %}
{% endif %}
{% for n, positions in position_groups %}

View file

@ -12,10 +12,7 @@
</h1>
<p class="my-3 alert alert-info">
You can begin managing the group state of this draft.
</p>
<p class="alert alert-info my-3">
For a WG, the draft enters the IETF stream.
For an RG, the draft enters the IRTF stream.
The document will be moved into the stream of the adopting group.
</p>
<form method="post">
{% csrf_token %}

View file

@ -0,0 +1,3 @@
{% load ietf_filters %}{% load mail_filters %}{% autoescape off %} {% filter wordwrap:78 %}The RSAB ballot for {{ doc.file_tag }} has been closed. The evaluation for this document can be found at {{ doc_url }}{% endfilter %}
{% endautoescape%}

View file

@ -0,0 +1,6 @@
{% load ietf_filters %}{% load mail_filters %}{% autoescape off %}{% filter wordwrap:78 %}Evaluation for {{ doc.file_tag }} can be found at {{ doc_url }}
{% endfilter %}
{% filter wordwrap:78 %}{{ needed_ballot_positions }}{% endfilter %}
{% endautoescape%}

View file

@ -0,0 +1,38 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2023, All Rights Reserved #}
{% load origin static %}
{% load ballot_icon %}
{% load ietf_filters %}
{% block pagehead %}
<link rel="stylesheet" href="{% static "ietf/css/list.css" %}">
{% endblock %}
{% block title %}RSAB ballot status{% endblock %}
{% block content %}
{% origin %}
<h1>RSAB ballot status</h1>
{% if docs %}
<table class="table table-sm table-striped tablesorter">
<thead>
<tr>
<th scope="col" data-sort="doc">Document</th>
<th scope="col" data-sort="status">Status</th>
</tr>
</thead>
<tbody>
{% for doc in docs %}
<tr>
<td>{{ doc.displayname_with_link }}</td>
{% include "doc/search/status_columns.html" %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="alert alert-info my-3">
No open RSAB ballots.
</p>
{% endif %}
{% endblock %}
{% block js %}
<script src="{% static "ietf/js/list.js" %}"></script>
{% endblock %}

View file

@ -87,6 +87,7 @@
{% csrf_token %}
<button class="btn btn-warning btn-sm"
type="submit"
value="Mark as action taken"
name="do_action_taken">
Mark as action taken
</button>

View file

@ -67,7 +67,7 @@ urlpatterns = [
url(r'^submit/', include('ietf.submit.urls')),
url(r'^sync/', include('ietf.sync.urls')),
url(r'^templates/', include('ietf.dbtemplate.urls')),
url(r'^(?P<group_type>(wg|rg|ag|rag|team|dir|review|area|program|iabasg|adhoc|ise|adm|rfcedtyp))/', include(grouptype_urls)),
url(r'^(?P<group_type>(wg|rg|ag|rag|team|dir|review|area|program|iabasg|adhoc|ise|adm|rfcedtyp|edwg|edappr))/', include(grouptype_urls)),
# Redirects
url(r'^(?P<path>public)/', include('ietf.redirects.urls')),

View file

@ -72,6 +72,10 @@ def make_immutable_base_data():
irtf = create_group(name="IRTF", acronym="irtf", type_id="irtf")
create_person(irtf, "chair")
rsab = create_group(name="RSAB", acronym="rsab", type_id="rfcedtyp")
p = create_person(rsab, "chair")
p.role_set.create(group=rsab, name_id="member", email=p.email())
secretariat = create_group(name="IETF Secretariat", acronym="secretariat", type_id="ietf")
create_person(secretariat, "secr", name="Sec Retary", username="secretary", is_staff=True, is_superuser=True)