Add BOF Requests to the datatracker. Commit ready for merge.

- Legacy-Id: 19228
This commit is contained in:
Robert Sparks 2021-07-15 20:15:54 +00:00
commit 84717102b0
50 changed files with 1829 additions and 59 deletions

View file

@ -1,4 +1,4 @@
# Copyright The IETF Trust 2010-2020, All Rights Reserved
# Copyright The IETF Trust 2010-2021, All Rights Reserved
# -*- coding: utf-8 -*-
@ -11,7 +11,8 @@ from .models import (StateType, State, RelatedDocument, DocumentAuthor, Document
StateDocEvent, ConsensusDocEvent, BallotType, BallotDocEvent, WriteupDocEvent, LastCallDocEvent,
TelechatDocEvent, BallotPositionDocEvent, ReviewRequestDocEvent, InitialReviewDocEvent,
AddedMessageEvent, SubmissionDocEvent, DeletedEvent, EditedAuthorsDocEvent, DocumentURL,
ReviewAssignmentDocEvent, IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource, DocumentActionHolder )
ReviewAssignmentDocEvent, IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource, DocumentActionHolder,
BofreqEditorDocEvent, BofreqResponsibleDocEvent )
from ietf.utils.validators import validate_external_resource_value
@ -193,6 +194,14 @@ class IRSGBallotDocEventAdmin(DocEventAdmin):
raw_id_fields = ["doc", "by"]
admin.site.register(IRSGBallotDocEvent, IRSGBallotDocEventAdmin)
class BofreqEditorDocEventAdmin(DocEventAdmin):
raw_id_fields = ["doc", "by", "editors" ]
admin.site.register(BofreqEditorDocEvent, BofreqEditorDocEventAdmin)
class BofreqResponsibleDocEventAdmin(DocEventAdmin):
raw_id_fields = ["doc", "by", "responsible" ]
admin.site.register(BofreqResponsibleDocEvent, BofreqResponsibleDocEventAdmin)
class DocumentUrlAdmin(admin.ModelAdmin):
list_display = ['id', 'doc', 'tag', 'url', 'desc', ]
search_fields = ['doc__name', 'url', ]

View file

@ -13,8 +13,12 @@ from django.conf import settings
from ietf.doc.models import ( Document, DocEvent, NewRevisionDocEvent, DocAlias, State, DocumentAuthor,
StateDocEvent, BallotPositionDocEvent, BallotDocEvent, BallotType, IRSGBallotDocEvent, TelechatDocEvent,
DocumentActionHolder)
DocumentActionHolder, BofreqEditorDocEvent, BofreqResponsibleDocEvent )
from ietf.group.models import Group
from ietf.person.factories import PersonFactory
from ietf.group.factories import RoleFactory
from ietf.utils.text import xslugify
def draft_name_generator(type_id,group,n):
return '%s-%s-%s-%s%d'%(
@ -379,3 +383,59 @@ class DocumentAuthorFactory(factory.DjangoModelFactory):
class WgDocumentAuthorFactory(DocumentAuthorFactory):
document = factory.SubFactory(WgDraftFactory)
class BofreqEditorDocEventFactory(DocEventFactory):
class Meta:
model = BofreqEditorDocEvent
type = "changed_editors"
doc = factory.SubFactory('ietf.doc.factories.BofreqFactory')
@factory.post_generation
def editors(obj, create, extracted, **kwargs):
if not create:
return
if extracted:
obj.editors.set(extracted)
else:
obj.editors.set(PersonFactory.create_batch(3))
obj.desc = f'Changed editors to {", ".join(obj.editors.values_list("name",flat=True)) or "(None)"}'
class BofreqResponsibleDocEventFactory(DocEventFactory):
class Meta:
model = BofreqResponsibleDocEvent
type = "changed_responsible"
doc = factory.SubFactory('ietf.doc.factories.BofreqFactory')
@factory.post_generation
def responsible(obj, create, extracted, **kwargs):
if not create:
return
if extracted:
obj.responsible.set(extracted)
else:
ad = RoleFactory(group__type_id='area',name_id='ad').person
obj.responsible.set([ad])
obj.desc = f'Changed responsible leadership to {", ".join(obj.responsible.values_list("name",flat=True)) or "(None)"}'
class BofreqFactory(BaseDocumentFactory):
type_id = 'bofreq'
title = factory.Faker('sentence')
name = factory.LazyAttribute(lambda o: 'bofreq-%s'%(xslugify(o.title)))
bofreqeditordocevent = factory.RelatedFactory('ietf.doc.factories.BofreqEditorDocEventFactory','doc')
bofreqresponsibledocevent = factory.RelatedFactory('ietf.doc.factories.BofreqResponsibleDocEventFactory','doc')
@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))
else:
obj.set_state(State.objects.get(type_id='bofreq',slug='proposed'))

View file

@ -20,6 +20,7 @@ from ietf.utils.mail import send_mail, send_mail_text
from ietf.ipr.utils import iprs_from_docs, related_docs
from ietf.doc.models import WriteupDocEvent, LastCallDocEvent, DocAlias, ConsensusDocEvent
from ietf.doc.utils import needed_ballot_positions
from ietf.doc.utils_bofreq import bofreq_editors, bofreq_responsible
from ietf.group.models import Role
from ietf.doc.models import Document
from ietf.mailtrigger.utils import gather_address_lists
@ -689,3 +690,43 @@ def send_external_resource_change_request(request, doc, submitter_info, requeste
doc_url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url(),
),
cc=list(cc),)
def email_bofreq_title_changed(request, bofreq):
addrs = gather_address_lists('bofreq_title_changed', doc=bofreq)
send_mail(request, addrs.to, settings.DEFAULT_FROM_EMAIL,
f'BOF Request title changed : {bofreq.name}',
'doc/mail/bofreq_title_changed.txt',
dict(bofreq=bofreq, request=request),
cc=addrs.cc)
def plain_names(persons):
return [p.plain_name() for p in persons]
def email_bofreq_editors_changed(request, bofreq, previous_editors):
editors = bofreq_editors(bofreq)
addrs = gather_address_lists('bofreq_editors_changed', doc=bofreq, previous_editors=previous_editors)
send_mail(request, addrs.to, settings.DEFAULT_FROM_EMAIL,
f'BOF Request editors changed : {bofreq.name}',
'doc/mail/bofreq_editors_changed.txt',
dict(bofreq=bofreq, request=request, editors=plain_names(editors), previous_editors=plain_names(previous_editors)),
cc=addrs.cc)
def email_bofreq_responsible_changed(request, bofreq, previous_responsible):
responsible = bofreq_responsible(bofreq)
addrs = gather_address_lists('bofreq_responsible_changed', doc=bofreq, previous_responsible=previous_responsible)
send_mail(request, addrs.to, settings.DEFAULT_FROM_EMAIL,
f'BOF Request responsible leadership changed : {bofreq.name}',
'doc/mail/bofreq_responsible_changed.txt',
dict(bofreq=bofreq, request=request, responsible=plain_names(responsible), previous_responsible=plain_names(previous_responsible)),
cc=addrs.cc)
def email_bofreq_new_revision(request, bofreq):
addrs = gather_address_lists('bofreq_new_revision', doc=bofreq)
send_mail(request, addrs.to, settings.DEFAULT_FROM_EMAIL,
f'New BOF request revision uploaded: {bofreq.name}-{bofreq.rev}',
'doc/mail/bofreq_new_revision.txt',
dict(bofreq=bofreq, request=request ),
cc=addrs.cc)

View file

@ -0,0 +1,36 @@
# Copyright The IETF Trust 2021 All Rights Reserved
# Generated by Django 2.2.23 on 2021-05-21 13:29
from django.db import migrations
def forward(apps, schema_editor):
StateType = apps.get_model('doc', 'StateType')
State = apps.get_model('doc', 'State')
StateType.objects.create(slug='bofreq', label='BOF Request State')
proposed = State.objects.create(type_id='bofreq', slug='proposed', name='Proposed', used=True, desc='The BOF request is proposed', order=0)
approved = State.objects.create(type_id='bofreq', slug='approved', name='Approved', used=True, desc='The BOF request is approved', order=1)
declined = State.objects.create(type_id='bofreq', slug='declined', name='Declined', used=True, desc='The BOF request is declined', order=2)
replaced = State.objects.create(type_id='bofreq', slug='replaced', name='Replaced', used=True, desc='The BOF request is proposed', order=3)
abandoned = State.objects.create(type_id='bofreq', slug='abandoned', name='Abandoned', used=True, desc='The BOF request is abandoned', order=4)
proposed.next_states.set([approved,declined,replaced,abandoned])
def reverse(apps, schema_editor):
StateType = apps.get_model('doc', 'StateType')
State = apps.get_model('doc', 'State')
State.objects.filter(type_id='bofreq').delete()
StateType.objects.filter(slug='bofreq').delete()
class Migration(migrations.Migration):
dependencies = [
('doc', '0041_add_documentactionholder'),
('name', '0027_add_bofrequest'),
]
operations = [
migrations.RunPython(forward, reverse)
]

View file

@ -0,0 +1,36 @@
# Generated by Django 2.2.24 on 2021-07-06 13:34
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('person', '0019_auto_20210604_1443'),
('doc', '0042_bofreq_states'),
]
operations = [
migrations.AlterField(
model_name='docevent',
name='type',
field=models.CharField(choices=[('new_revision', 'Added new revision'), ('new_submission', 'Uploaded new revision'), ('changed_document', 'Changed document metadata'), ('added_comment', 'Added comment'), ('added_message', 'Added message'), ('edited_authors', 'Edited the documents author list'), ('deleted', 'Deleted document'), ('changed_state', 'Changed state'), ('changed_stream', 'Changed document stream'), ('expired_document', 'Expired document'), ('extended_expiry', 'Extended expiry of document'), ('requested_resurrect', 'Requested resurrect'), ('completed_resurrect', 'Completed resurrect'), ('changed_consensus', 'Changed consensus'), ('published_rfc', 'Published RFC'), ('added_suggested_replaces', 'Added suggested replacement relationships'), ('reviewed_suggested_replaces', 'Reviewed suggested replacement relationships'), ('changed_action_holders', 'Changed action holders for document'), ('changed_group', 'Changed group'), ('changed_protocol_writeup', 'Changed protocol writeup'), ('changed_charter_milestone', 'Changed charter milestone'), ('initial_review', 'Set initial review time'), ('changed_review_announcement', 'Changed WG Review text'), ('changed_action_announcement', 'Changed WG Action text'), ('started_iesg_process', 'Started IESG process on document'), ('created_ballot', 'Created ballot'), ('closed_ballot', 'Closed ballot'), ('sent_ballot_announcement', 'Sent ballot announcement'), ('changed_ballot_position', 'Changed ballot position'), ('changed_ballot_approval_text', 'Changed ballot approval text'), ('changed_ballot_writeup_text', 'Changed ballot writeup text'), ('changed_rfc_editor_note_text', 'Changed RFC Editor Note text'), ('changed_last_call_text', 'Changed last call text'), ('requested_last_call', 'Requested last call'), ('sent_last_call', 'Sent last call'), ('scheduled_for_telechat', 'Scheduled for telechat'), ('iesg_approved', 'IESG approved document (no problem)'), ('iesg_disapproved', 'IESG disapproved document (do not publish)'), ('approved_in_minute', 'Approved in minute'), ('iana_review', 'IANA review comment'), ('rfc_in_iana_registry', 'RFC is in IANA registry'), ('rfc_editor_received_announcement', 'Announcement was received by RFC Editor'), ('requested_publication', 'Publication at RFC Editor requested'), ('sync_from_rfc_editor', 'Received updated information from RFC Editor'), ('requested_review', 'Requested review'), ('assigned_review_request', 'Assigned review request'), ('closed_review_request', 'Closed review request'), ('closed_review_assignment', 'Closed review assignment'), ('downref_approved', 'Downref approved'), ('posted_related_ipr', 'Posted related IPR'), ('removed_related_ipr', 'Removed related IPR'), ('changed_editors', 'Changed BOF Request editors')], max_length=50),
),
migrations.CreateModel(
name='BofreqResponsibleDocEvent',
fields=[
('docevent_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='doc.DocEvent')),
('responsible', models.ManyToManyField(blank=True, to='person.Person')),
],
bases=('doc.docevent',),
),
migrations.CreateModel(
name='BofreqEditorDocEvent',
fields=[
('docevent_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='doc.DocEvent')),
('editors', models.ManyToManyField(blank=True, to='person.Person')),
],
bases=('doc.docevent',),
),
]

View file

@ -142,6 +142,8 @@ class DocumentInfo(models.Model):
self._cached_file_path = settings.CONFLICT_REVIEW_PATH
elif self.type_id == "statchg":
self._cached_file_path = settings.STATUS_CHANGE_PATH
elif self.type_id == "bofreq":
self._cached_file_path = settings.BOFREQ_PATH
else:
self._cached_file_path = settings.DOCUMENT_PATH_PATTERN.format(doc=self)
return self._cached_file_path
@ -163,6 +165,8 @@ class DocumentInfo(models.Model):
elif self.type_id == 'review':
# TODO: This will be wrong if a review is updated on the same day it was created (or updated more than once on the same day)
self._cached_base_name = "%s.txt" % self.name
elif self.type_id == 'bofreq':
self._cached_base_name = "%s-%s.md" % (self.name, self.rev)
else:
if self.rev:
self._cached_base_name = "%s-%s.txt" % (self.canonical_name(), self.rev)
@ -1145,6 +1149,9 @@ EVENT_TYPES = [
# IPR events
("posted_related_ipr", "Posted related IPR"),
("removed_related_ipr", "Removed related IPR"),
# Bofreq Editor events
("changed_editors", "Changed BOF Request editors")
]
class DocEvent(models.Model):
@ -1340,3 +1347,11 @@ class EditedAuthorsDocEvent(DocEvent):
Example 'basis' values might be from ['manually adjusted','recomputed by parsing document', etc.]
"""
basis = models.CharField(help_text="What is the source or reasoning for the changes to the author list",max_length=255)
class BofreqEditorDocEvent(DocEvent):
""" Capture the proponents of a BOF Request."""
editors = models.ManyToManyField('person.Person', blank=True)
class BofreqResponsibleDocEvent(DocEvent):
""" Capture the responsible leadership (IAB and IESG members) for a BOF Request """
responsible = models.ManyToManyField('person.Person', blank=True)

View file

@ -17,7 +17,8 @@ from ietf.doc.models import (BallotType, DeletedEvent, StateType, State, Documen
InitialReviewDocEvent, DocHistoryAuthor, BallotDocEvent, RelatedDocument,
RelatedDocHistory, BallotPositionDocEvent, AddedMessageEvent, SubmissionDocEvent,
ReviewRequestDocEvent, ReviewAssignmentDocEvent, EditedAuthorsDocEvent, DocumentURL,
IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource, DocumentActionHolder )
IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource, DocumentActionHolder,
BofreqEditorDocEvent,BofreqResponsibleDocEvent)
from ietf.name.resources import BallotPositionNameResource, DocTypeNameResource
class BallotTypeResource(ModelResource):
@ -806,3 +807,55 @@ class DocumentActionHolderResource(ModelResource):
"person": ALL_WITH_RELATIONS,
}
api.doc.register(DocumentActionHolderResource())
from ietf.person.resources import PersonResource
class BofreqEditorDocEventResource(ModelResource):
by = ToOneField(PersonResource, 'by')
doc = ToOneField(DocumentResource, 'doc')
docevent_ptr = ToOneField(DocEventResource, 'docevent_ptr')
editors = ToManyField(PersonResource, 'editors', null=True)
class Meta:
queryset = BofreqEditorDocEvent.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'bofreqeditordocevent'
ordering = ['docevent_ptr', ]
filtering = {
"id": ALL,
"time": ALL,
"type": ALL,
"rev": ALL,
"desc": ALL,
"by": ALL_WITH_RELATIONS,
"doc": ALL_WITH_RELATIONS,
"docevent_ptr": ALL_WITH_RELATIONS,
"editors": ALL_WITH_RELATIONS,
}
api.doc.register(BofreqEditorDocEventResource())
from ietf.person.resources import PersonResource
class BofreqResponsibleDocEventResource(ModelResource):
by = ToOneField(PersonResource, 'by')
doc = ToOneField(DocumentResource, 'doc')
docevent_ptr = ToOneField(DocEventResource, 'docevent_ptr')
responsible = ToManyField(PersonResource, 'responsible', null=True)
class Meta:
queryset = BofreqResponsibleDocEvent.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'bofreqresponsibledocevent'
ordering = ['docevent_ptr', ]
filtering = {
"id": ALL,
"time": ALL,
"type": ALL,
"rev": ALL,
"desc": ALL,
"by": ALL_WITH_RELATIONS,
"doc": ALL_WITH_RELATIONS,
"docevent_ptr": ALL_WITH_RELATIONS,
"responsible": ALL_WITH_RELATIONS,
}
api.doc.register(BofreqResponsibleDocEventResource())

393
ietf/doc/tests_bofreq.py Normal file
View file

@ -0,0 +1,393 @@
# Copyright The IETF Trust 2021 All Rights Reserved
import datetime
import debug # pyflakes:ignore
import io
import os
import shutil
from pyquery import PyQuery
from random import randint
from tempfile import NamedTemporaryFile
from django.conf import settings
from django.urls import reverse as urlreverse
from django.template.loader import render_to_string
from ietf.group.factories import RoleFactory
from ietf.doc.factories import BofreqFactory, NewRevisionDocEventFactory
from ietf.doc.models import State, Document, DocAlias, NewRevisionDocEvent
from ietf.doc.utils_bofreq import bofreq_editors, bofreq_responsible
from ietf.person.factories import PersonFactory
from ietf.utils.mail import outbox, empty_outbox
from ietf.utils.test_utils import TestCase, reload_db_objects, unicontent, login_testing_unauthorized
class BofreqTests(TestCase):
def setUp(self):
self.bofreq_dir = self.tempdir('bofreq')
self.saved_bofreq_path = settings.BOFREQ_PATH
settings.BOFREQ_PATH = self.bofreq_dir
def tearDown(self):
settings.BOFREQ_PATH = self.saved_bofreq_path
shutil.rmtree(self.bofreq_dir)
def write_bofreq_file(self, bofreq):
fname = os.path.join(self.bofreq_dir, "%s-%s.md" % (bofreq.canonical_name(), bofreq.rev))
with io.open(fname, "w") as f:
f.write(f"""# This is a test bofreq.
Version: {bofreq.rev}
## A section
This test section has some text.
""")
def test_show_bof_requests(self):
url = urlreverse('ietf.doc.views_bofreq.bof_requests')
r = self.client.get(url)
self.assertContains(r, 'There are currently no BOF Requests', status_code=200)
states = State.objects.filter(type_id='bofreq')
self.assertTrue(states.count()>0)
for i in range(3*len(states)):
BofreqFactory(states=[('bofreq',states[i%len(states)].slug)],newrevisiondocevent__time=datetime.datetime.today()-datetime.timedelta(days=randint(0,20)))
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
for state in states:
self.assertEqual(len(q(f'#bofreqs-{state.slug}')), 1)
self.assertEqual(len(q(f'#bofreqs-{state.slug} tbody tr')), 3)
self.assertFalse(q('#start_button'))
PersonFactory(user__username='nobody')
self.client.login(username='nobody', password='nobody+password')
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
self.assertTrue(q('#start_button'))
def test_bofreq_main_page(self):
doc = BofreqFactory()
doc.save_with_history(doc.docevent_set.all())
self.write_bofreq_file(doc)
nr_event = NewRevisionDocEventFactory(doc=doc,rev='01')
doc.rev='01'
doc.save_with_history([nr_event])
self.write_bofreq_file(doc)
editors = bofreq_editors(doc)
responsible = bofreq_responsible(doc)
url = urlreverse('ietf.doc.views_doc.document_main', kwargs=dict(name=doc.name))
r = self.client.get(url)
self.assertContains(r,'Version: 01',status_code=200)
q = PyQuery(r.content)
self.assertEqual(0, len(q('td.edit>a.btn')))
self.assertEqual([],q('#change-request'))
editor_row = q('#editors').html()
for editor in editors:
self.assertInHTML(editor.plain_name(),editor_row)
responsible_row = q('#responsible').html()
for leader in responsible:
self.assertInHTML(leader.plain_name(),responsible_row)
for user in ('secretary','ad','iab-member'):
self.client.login(username=user,password=user+"+password")
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
self.assertEqual(6, len(q('td.edit>a.btn')))
self.client.logout()
self.assertNotEqual([],q('#change-request'))
editor = editors.first().user.username
self.client.login(username=editor, password=editor+"+password")
r = self.client.get(url)
self.assertEqual(r.status_code,200)
q = PyQuery(r.content)
self.assertEqual(3, len(q('td.edit>a.btn')))
self.assertNotEqual([],q('#change-request'))
self.client.logout()
url = urlreverse('ietf.doc.views_doc.document_main', kwargs=dict(name=doc,rev='00'))
r = self.client.get(url)
self.assertContains(r,'Version: 00',status_code=200)
self.assertContains(r,'is for an older version')
def test_edit_title(self):
doc = BofreqFactory()
editor = bofreq_editors(doc).first()
url = urlreverse('ietf.doc.views_bofreq.edit_title', kwargs=dict(name=doc.name))
title = doc.title
r = self.client.post(url,dict(title='New title'))
self.assertEqual(r.status_code, 302)
doc = reload_db_objects(doc)
self.assertEqual(title, doc.title)
nobody = PersonFactory()
self.client.login(username=nobody.user.username,password=nobody.user.username+'+password')
r = self.client.post(url,dict(title='New title'))
self.assertEqual(r.status_code, 403)
doc = reload_db_objects(doc)
self.assertEqual(title, doc.title)
self.client.logout()
for username in ('secretary', 'ad', 'iab-member', editor.user.username):
self.client.login(username=username, password=username+'+password')
r = self.client.get(url)
self.assertEqual(r.status_code,200)
docevent_count = doc.docevent_set.count()
empty_outbox()
r = self.client.post(url,dict(title=username))
self.assertEqual(r.status_code,302)
doc = reload_db_objects(doc)
self.assertEqual(doc.title, username)
self.assertEqual(docevent_count+1, doc.docevent_set.count())
self.assertEqual(1, len(outbox))
self.client.logout()
def state_pk_as_str(self, type_id, slug):
return str(State.objects.get(type_id=type_id, slug=slug).pk)
def test_edit_state(self):
doc = BofreqFactory()
editor = bofreq_editors(doc).first()
url = urlreverse('ietf.doc.views_bofreq.change_state', kwargs=dict(name=doc.name))
state = doc.get_state('bofreq')
r = self.client.post(url, dict(new_state=self.state_pk_as_str('bofreq','approved')))
self.assertEqual(r.status_code, 302)
doc = reload_db_objects(doc)
self.assertEqual(state, doc.get_state('bofreq'))
self.client.login(username=editor.user.username,password=editor.user.username+'+password')
r = self.client.post(url, dict(new_state=self.state_pk_as_str('bofreq','approved')))
self.assertEqual(r.status_code, 403)
doc = reload_db_objects(doc)
self.assertEqual(state,doc.get_state('bofreq'))
self.client.logout()
for username in ('secretary', 'ad', 'iab-member'):
doc.set_state(state)
self.client.login(username=username,password=username+'+password')
r = self.client.get(url)
self.assertEqual(r.status_code,200)
docevent_count = doc.docevent_set.count()
r = self.client.post(url,dict(new_state=self.state_pk_as_str('bofreq','approved' if username=='secretary' else 'declined'),comment=f'{username}-2309hnf'))
self.assertEqual(r.status_code,302)
doc = reload_db_objects(doc)
self.assertEqual('approved' if username=='secretary' else 'declined',doc.get_state_slug('bofreq'))
self.assertEqual(docevent_count+2, doc.docevent_set.count())
self.assertIn(f'{username}-2309hnf',doc.latest_event(type='added_comment').desc)
self.client.logout()
def test_change_editors(self):
doc = BofreqFactory()
previous_editors = list(bofreq_editors(doc))
acting_editor = previous_editors[0]
new_editors = set(previous_editors)
new_editors.discard(acting_editor)
new_editors.add(PersonFactory())
url = urlreverse('ietf.doc.views_bofreq.change_editors', kwargs=dict(name=doc.name))
postdict = dict(editors=','.join([str(p.pk) for p in new_editors]))
r = self.client.post(url, postdict)
self.assertEqual(r.status_code,302)
editors = bofreq_editors(doc)
self.assertEqual(set(previous_editors),set(editors))
nobody = PersonFactory()
self.client.login(username=nobody.user.username,password=nobody.user.username+'+password')
r = self.client.post(url, postdict)
self.assertEqual(r.status_code,403)
editors = bofreq_editors(doc)
self.assertEqual(set(previous_editors),set(editors))
self.client.logout()
for username in (previous_editors[0].user.username, 'secretary', 'ad', 'iab-member'):
empty_outbox()
self.client.login(username=username,password=username+'+password')
r = self.client.get(url)
self.assertEqual(r.status_code,200)
unescaped = unicontent(r).encode('utf-8').decode('unicode-escape')
for editor in previous_editors:
self.assertIn(editor.name,unescaped)
new_editors = set(previous_editors)
new_editors.discard(acting_editor)
new_editors.add(PersonFactory())
postdict = dict(editors=','.join([str(p.pk) for p in new_editors]))
r = self.client.post(url,postdict)
self.assertEqual(r.status_code, 302)
updated_editors = bofreq_editors(doc)
self.assertEqual(new_editors,set(updated_editors))
previous_editors = new_editors
self.client.logout()
self.assertEqual(len(outbox),1)
self.assertIn('BOF Request editors changed',outbox[0]['Subject'])
def test_change_responsible(self):
doc = BofreqFactory()
previous_responsible = list(bofreq_responsible(doc))
new_responsible = set(previous_responsible[1:])
new_responsible.add(RoleFactory(group__type_id='area',name_id='ad').person)
url = urlreverse('ietf.doc.views_bofreq.change_responsible', kwargs=dict(name=doc.name))
postdict = dict(responsible=','.join([str(p.pk) for p in new_responsible]))
r = self.client.post(url, postdict)
self.assertEqual(r.status_code,302)
responsible = bofreq_responsible(doc)
self.assertEqual(set(previous_responsible), set(responsible))
PersonFactory(user__username='nobody')
self.client.login(username='nobody',password='nobody+password')
r = self.client.post(url, postdict)
self.assertEqual(r.status_code,403)
responsible = bofreq_responsible(doc)
self.assertEqual(set(previous_responsible), set(responsible))
self.client.logout()
for username in ('secretary', 'ad', 'iab-member'):
empty_outbox()
self.client.login(username=username,password=username+'+password')
r = self.client.get(url)
self.assertEqual(r.status_code,200)
unescaped = unicontent(r).encode('utf-8').decode('unicode-escape')
for responsible in previous_responsible:
self.assertIn(responsible.name,unescaped)
new_responsible = set(previous_responsible)
new_responsible.add(RoleFactory(group__type_id='area',name_id='ad').person)
postdict = dict(responsible=','.join([str(p.pk) for p in new_responsible]))
r = self.client.post(url,postdict)
self.assertEqual(r.status_code, 302)
updated_responsible = bofreq_responsible(doc)
self.assertEqual(new_responsible,set(updated_responsible))
previous_responsible = new_responsible
self.client.logout()
self.assertEqual(len(outbox),1)
self.assertIn('BOF Request responsible leadership changed',outbox[0]['Subject'])
def test_change_responsible_validation(self):
doc = BofreqFactory()
url = urlreverse('ietf.doc.views_bofreq.change_responsible', kwargs=dict(name=doc.name))
login_testing_unauthorized(self,'secretary',url)
bad_batch = PersonFactory.create_batch(3)
good_batch = list()
good_batch.append(RoleFactory(group__type_id='area', name_id='ad').person)
good_batch.append(RoleFactory(group__acronym='iab', name_id='member').person)
pks = set()
pks.update([p.pk for p in good_batch])
pks.update([p.pk for p in bad_batch])
postdict = dict(responsible=','.join([str(pk) for pk in pks]))
r = self.client.post(url,postdict)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
error_text = q('.has-error .alert').text()
for p in good_batch:
self.assertNotIn(p.plain_name(), error_text)
for p in bad_batch:
self.assertIn(p.plain_name(), error_text)
def test_submit(self):
doc = BofreqFactory()
url = urlreverse('ietf.doc.views_bofreq.submit', kwargs=dict(name=doc.name))
rev = doc.rev
r = self.client.post(url,{'bofreq_submission':'enter','bofreq_content':'# oiwefrase'})
self.assertEqual(r.status_code, 302)
doc = reload_db_objects(doc)
self.assertEqual(rev, doc.rev)
nobody = PersonFactory()
self.client.login(username=nobody.user.username, password=nobody.user.username+'+password')
r = self.client.post(url,{'bofreq_submission':'enter','bofreq_content':'# oiwefrase'})
self.assertEqual(r.status_code, 403)
doc = reload_db_objects(doc)
self.assertEqual(rev, doc.rev)
self.client.logout()
editor = bofreq_editors(doc).first()
for username in ('secretary', 'ad', 'iab-member', editor.user.username):
self.client.login(username=username, password=username+'+password')
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
file = NamedTemporaryFile(delete=False,mode="w+",encoding='utf-8')
file.write(f'# {username}')
file.close()
for postdict in [
{'bofreq_submission':'enter','bofreq_content':f'# {username}'},
{'bofreq_submission':'upload','bofreq_file':open(file.name,'rb')},
]:
docevent_count = doc.docevent_set.count()
empty_outbox()
r = self.client.post(url, postdict)
self.assertEqual(r.status_code, 302)
doc = reload_db_objects(doc)
self.assertEqual('%02d'%(int(rev)+1) ,doc.rev)
self.assertEqual(f'# {username}', doc.text())
self.assertEqual(docevent_count+1, doc.docevent_set.count())
self.assertEqual(1, len(outbox))
rev = doc.rev
self.client.logout()
os.unlink(file.name)
def test_start_new_bofreq(self):
url = urlreverse('ietf.doc.views_bofreq.new_bof_request')
nobody = PersonFactory()
login_testing_unauthorized(self,nobody.user.username,url)
r = self.client.get(url)
self.assertContains(r,'Fill in the details below. Keep items in the order they appear here.',status_code=200)
r = self.client.post(url, dict(title='default',
bofreq_submission='enter',
bofreq_content=render_to_string('doc/bofreq/bofreq_template.md',{})))
self.assertContains(r, 'The example content may not be saved.', status_code=200)
file = NamedTemporaryFile(delete=False,mode="w+",encoding='utf-8')
file.write('some stuff')
file.close()
for postdict in [
dict(title='title one', bofreq_submission='enter', bofreq_content='some stuff'),
dict(title='title two', bofreq_submission='upload', bofreq_file=open(file.name,'rb')),
]:
empty_outbox()
r = self.client.post(url, postdict)
self.assertEqual(r.status_code,302)
name = f"bofreq-{postdict['title']}".replace(' ','-')
bofreq = Document.objects.filter(name=name,type_id='bofreq').first()
self.assertIsNotNone(bofreq)
self.assertIsNotNone(DocAlias.objects.filter(name=name).first())
self.assertEqual(bofreq.title, postdict['title'])
self.assertEqual(bofreq.rev, '00')
self.assertEqual(bofreq.get_state_slug(), 'proposed')
self.assertEqual(list(bofreq_editors(bofreq)), [nobody])
self.assertEqual(bofreq.latest_event(NewRevisionDocEvent).rev, '00')
self.assertEqual(bofreq.text_or_error(), 'some stuff')
self.assertEqual(len(outbox),1)
os.unlink(file.name)
existing_bofreq = BofreqFactory()
for postdict in [
dict(title='', bofreq_submission='enter', bofreq_content='some stuff'),
dict(title='a title', bofreq_submission='enter', bofreq_content=''),
dict(title=existing_bofreq.title, bofreq_submission='enter', bofreq_content='some stuff'),
dict(title='森川', bofreq_submission='enter', bofreq_content='some stuff'),
dict(title='a title', bofreq_submission='', bofreq_content='some stuff'),
]:
r = self.client.post(url,postdict)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
self.assertTrue(q('form div.has-error'))
def test_post_proposed_restrictions(self):
states = State.objects.filter(type_id='bofreq').exclude(slug='proposed')
bofreq = BofreqFactory()
editor = bofreq_editors(bofreq).first()
for view in ('submit', 'change_editors', 'edit_title'):
url = urlreverse(f'ietf.doc.views_bofreq.{view}', kwargs=dict(name=bofreq.name))
for state in states:
bofreq.set_state(state)
for username in ('secretary', 'ad', 'iab-member'):
self.client.login(username=username, password=username+'+password')
r = self.client.get(url)
self.assertEqual(r.status_code,200)
self.client.logout()
self.client.login(username=editor.user.username, password=editor.user.username+'+password')
r = self.client.get(url)
self.assertEqual(r.status_code, 403, f'editor should not be able to use {view} in state {state.slug}')
self.client.logout()
url = urlreverse('ietf.doc.views_doc.document_main', kwargs=dict(name=bofreq.name))
self.client.login(username=editor.user.username, password=editor.user.username+'+password')
r = self.client.get(url)
self.assertEqual(r.status_code,200)
q = PyQuery(r.content)
self.assertEqual(0, len(q('td.edit>a.btn')))
self.assertEqual([],q('#change-request'))

View file

@ -275,7 +275,7 @@ class EditCharterTests(TestCase):
login_testing_unauthorized(self, "secretary", url)
response=self.client.get(url)
self.assertEqual(response.status_code,200)
response = self.client.post(url,{'comment':'Testing Abandoning a Bof Charter'})
response = self.client.post(url,{'comment':'Testing Abandoning a BOF Charter'})
self.assertEqual(response.status_code,302)
charter = Document.objects.get(pk=charter.pk)
self.assertEqual(charter.group.state_id,'abandon')

View file

@ -37,7 +37,7 @@ from django.conf.urls import include
from django.views.generic import RedirectView
from django.conf import settings
from ietf.doc import views_search, views_draft, views_ballot, views_status_change, views_doc, views_downref, views_stats, views_help
from ietf.doc import views_search, views_draft, views_ballot, views_status_change, views_doc, views_downref, views_stats, views_help, views_bofreq
from ietf.utils.urls import url
session_patterns = [
@ -54,6 +54,8 @@ urlpatterns = [
url(r'^ad2/(?P<name>[\w.-]+)/$', RedirectView.as_view(url='/doc/ad/%(name)s/', permanent=True)),
url(r'^rfc-status-changes/?$', views_status_change.rfc_status_changes),
url(r'^start-rfc-status-change/(?:%(name)s/)?$' % settings.URL_REGEXPS, views_status_change.start_rfc_status_change),
url(r'^bof-requests/?$', views_bofreq.bof_requests),
url(r'^bof-requests/new/$', views_bofreq.new_bof_request),
url(r'^iesg/?$', views_search.drafts_in_iesg_process),
url(r'^email-aliases/?$', views_doc.email_aliases),
url(r'^downref/?$', views_downref.downref_registry),
@ -145,6 +147,7 @@ urlpatterns = [
url(r'^%(name)s/meetings/?$' % settings.URL_REGEXPS, views_doc.all_presentations),
url(r'^%(charter)s/' % settings.URL_REGEXPS, include('ietf.doc.urls_charter')),
url(r'^%(bofreq)s/' % settings.URL_REGEXPS, include('ietf.doc.urls_bofreq')),
url(r'^%(name)s/conflict-review/' % settings.URL_REGEXPS, include('ietf.doc.urls_conflict_review')),
url(r'^%(name)s/status-change/' % settings.URL_REGEXPS, include('ietf.doc.urls_status_change')),
url(r'^%(name)s/material/' % settings.URL_REGEXPS, include('ietf.doc.urls_material')),

11
ietf/doc/urls_bofreq.py Normal file
View file

@ -0,0 +1,11 @@
from ietf.doc import views_bofreq
from ietf.utils.urls import url
urlpatterns = [
url(r'^state/$', views_bofreq.change_state),
url(r'^submit/$', views_bofreq.submit),
url(r'^title/$', views_bofreq.edit_title),
url(r'^editors/$', views_bofreq.change_editors),
url(r'^responsible/$', views_bofreq.change_responsible),
]

View file

@ -29,7 +29,7 @@ from ietf.doc.models import DocEvent, ConsensusDocEvent, BallotDocEvent, IRSGBal
from ietf.doc.models import TelechatDocEvent, DocumentActionHolder, EditedAuthorsDocEvent
from ietf.name.models import DocReminderTypeName, DocRelationshipName
from ietf.group.models import Role, Group
from ietf.ietfauth.utils import has_role, is_authorized_in_doc_stream, is_individual_draft_author
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
from ietf.utils import draft, text
@ -152,7 +152,8 @@ def can_unadopt_draft(user, doc):
def can_edit_docextresources(user, doc):
return (has_role(user, ("Secretariat", "Area Director"))
or is_authorized_in_doc_stream(user, doc)
or is_individual_draft_author(user, doc))
or is_individual_draft_author(user, doc)
or is_bofreq_editor(user, doc))
def two_thirds_rule( recused=0 ):
# For standards-track, need positions from 2/3 of the non-recused current IESG.
@ -1219,13 +1220,15 @@ def update_doc_extresources(doc, new_resources, by):
if old_res_strs == new_res_strs:
return False # no change
old_res_strs = f'\n\n{old_res_strs}\n\n' if old_res_strs else ' None '
new_res_strs = f'\n\n{new_res_strs}' if new_res_strs else ' None'
doc.docextresource_set.all().delete()
for new_res in new_resources:
new_res.doc = doc
new_res.save()
e = DocEvent(doc=doc, rev=doc.rev, by=by, type='changed_document')
e.desc = "Changed document external resources from:\n\n%s\n\nto:\n\n%s" % (
old_res_strs, new_res_strs)
e.desc = f"Changed document external resources from:{old_res_strs}to:{new_res_strs}"
e.save()
doc.save_with_history([e])
return True

12
ietf/doc/utils_bofreq.py Normal file
View file

@ -0,0 +1,12 @@
# Copyright The IETF Trust 2021 All Rights Reserved
from ietf.doc.models import BofreqEditorDocEvent, BofreqResponsibleDocEvent
from ietf.person.models import Person
def bofreq_editors(bofreq):
e = bofreq.latest_event(BofreqEditorDocEvent)
return e.editors.all() if e else Person.objects.none()
def bofreq_responsible(bofreq):
e = bofreq.latest_event(BofreqResponsibleDocEvent)
return e.responsible.all() if e else Person.objects.none()

348
ietf/doc/views_bofreq.py Normal file
View file

@ -0,0 +1,348 @@
# Copyright The IETF Trust 2021 All Rights Reserved
import debug # pyflakes:ignore
import io
import markdown
from django import forms
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse as urlreverse
from ietf.doc.mails import (email_bofreq_title_changed, email_bofreq_editors_changed,
email_bofreq_new_revision, email_bofreq_responsible_changed)
from ietf.doc.models import (Document, DocAlias, DocEvent, NewRevisionDocEvent,
BofreqEditorDocEvent, BofreqResponsibleDocEvent, State)
from ietf.doc.utils import add_state_change_event
from ietf.doc.utils_bofreq import bofreq_editors, bofreq_responsible
from ietf.ietfauth.utils import has_role, role_required
from ietf.person.fields import SearchablePersonsField
from ietf.utils.response import permission_denied
from ietf.utils.text import xslugify
from ietf.utils.textupload import get_cleaned_text_file_content
def bof_requests(request):
reqs = Document.objects.filter(type_id='bofreq')
for req in reqs:
req.latest_revision_event = req.latest_event(NewRevisionDocEvent)
req.responsible = bofreq_responsible(req)
req.editors = bofreq_editors(req)
sorted_reqs = sorted(sorted(reqs, key=lambda doc: doc.latest_revision_event.time, reverse=True), key=lambda doc: doc.get_state().order)
return render(request, 'doc/bofreq/bof_requests.html',dict(reqs=sorted_reqs))
class BofreqUploadForm(forms.Form):
ACTIONS = [
("enter", "Enter content directly"),
("upload", "Upload content from file"),
]
bofreq_submission = forms.ChoiceField(choices=ACTIONS, widget=forms.RadioSelect)
bofreq_file = forms.FileField(label="Markdown source file to upload", required=False)
bofreq_content = forms.CharField(widget=forms.Textarea(attrs={'rows':30}), required=False, strip=False)
def clean_bofreq_content(self):
content = self.cleaned_data["bofreq_content"].replace("\r", "")
default_content = render_to_string('doc/bofreq/bofreq_template.md',{})
if content==default_content:
raise forms.ValidationError('The example content may not be saved. Edit it as instructed to document this BOF request.')
try:
_ = markdown.markdown(content, extensions=['extra'])
except Exception as e:
raise forms.ValidationError(f'Markdown processing failed: {e}')
return content
def clean_bofreq_file(self):
content = get_cleaned_text_file_content(self.cleaned_data["bofreq_file"])
try:
_ = markdown.markdown(content, extensions=['extra'])
except Exception as e:
raise forms.ValidationError(f'Markdown processing failed: {e}')
return content
def clean(self):
def require_field(f):
if not self.cleaned_data.get(f):
self.add_error(f, forms.ValidationError("You must fill in this field."))
submission_method = self.cleaned_data.get("bofreq_submission")
if submission_method == "enter":
require_field("bofreq_content")
elif submission_method == "upload":
require_field("bofreq_file")
@login_required
def submit(request, name):
bofreq = get_object_or_404(Document, type="bofreq", name=name)
previous_editors = bofreq_editors(bofreq)
state_id = bofreq.get_state_slug('bofreq')
if not (has_role(request.user,('Secretariat', 'Area Director', 'IAB')) or (state_id=='proposed' and request.user.person in previous_editors)):
permission_denied(request,"You do not have permission to upload a new revision of this BOF Request")
if request.method == 'POST':
form = BofreqUploadForm(request.POST, request.FILES)
if form.is_valid():
bofreq.rev = "%02d" % (int(bofreq.rev)+1)
e = NewRevisionDocEvent.objects.create(
type="new_revision",
doc=bofreq,
by=request.user.person,
rev=bofreq.rev,
desc='New revision available',
time=bofreq.time,
)
bofreq.save_with_history([e])
bofreq_submission = form.cleaned_data['bofreq_submission']
if bofreq_submission == "upload":
content = form.cleaned_data['bofreq_file']
else:
content = form.cleaned_data['bofreq_content']
with io.open(bofreq.get_file_name(), 'w', encoding='utf-8') as destination:
destination.write(content)
email_bofreq_new_revision(request, bofreq)
return redirect('ietf.doc.views_doc.document_main', name=bofreq.name)
else:
init = {'bofreq_content':bofreq.text_or_error(),
'bofreq_submission':'enter',
}
form = BofreqUploadForm(initial=init)
return render(request, 'doc/bofreq/upload_content.html',
{'form':form,'doc':bofreq})
class NewBofreqForm(BofreqUploadForm):
title = forms.CharField(max_length=255)
field_order = ['title','bofreq_submission','bofreq_file','bofreq_content']
def name_from_title(self,title):
name = 'bofreq-' + xslugify(title).replace('_', '-')[:128]
return name
def clean_title(self):
title = self.cleaned_data['title']
name = self.name_from_title(title)
if name == 'bofreq-':
raise forms.ValidationError('The filename derived from this title is empty. Please include a few descriptive words using ascii or numeric characters')
if Document.objects.filter(name=name).exists():
raise forms.ValidationError('This title produces a filename already used by an existing BOF request')
return title
@login_required
def new_bof_request(request):
if request.method == 'POST':
form = NewBofreqForm(request.POST, request.FILES)
if form.is_valid():
title = form.cleaned_data['title']
name = form.name_from_title(title)
bofreq = Document.objects.create(
type_id='bofreq',
name = name,
title = title,
abstract = '',
rev = '00',
)
bofreq.set_state(State.objects.get(type_id='bofreq',slug='proposed'))
e1 = NewRevisionDocEvent.objects.create(
type="new_revision",
doc=bofreq,
by=request.user.person,
rev=bofreq.rev,
desc='New revision available',
time=bofreq.time,
)
e2 = BofreqEditorDocEvent.objects.create(
type="changed_editors",
doc=bofreq,
rev=bofreq.rev,
by=request.user.person,
desc= f'Editors changed to {request.user.person.name}',
)
e2.editors.set([request.user.person])
bofreq.save_with_history([e1,e2])
alias = DocAlias.objects.create(name=name)
alias.docs.set([bofreq])
bofreq_submission = form.cleaned_data['bofreq_submission']
if bofreq_submission == "upload":
content = form.cleaned_data['bofreq_file']
else:
content = form.cleaned_data['bofreq_content']
with io.open(bofreq.get_file_name(), 'w', encoding='utf-8') as destination:
destination.write(content)
email_bofreq_new_revision(request, bofreq)
return redirect('ietf.doc.views_doc.document_main', name=bofreq.name)
else:
init = {'bofreq_content':render_to_string('doc/bofreq/bofreq_template.md',{}),
'bofreq_submission':'enter',
}
form = NewBofreqForm(initial=init)
return render(request, 'doc/bofreq/new_bofreq.html',
{'form':form})
class ChangeEditorsForm(forms.Form):
editors = SearchablePersonsField(required=False)
@login_required
def change_editors(request, name):
bofreq = get_object_or_404(Document, type="bofreq", name=name)
previous_editors = bofreq_editors(bofreq)
state_id = bofreq.get_state_slug('bofreq')
if not (has_role(request.user,('Secretariat', 'Area Director', 'IAB')) or (state_id=='proposed' and request.user.person in previous_editors)):
permission_denied(request,"You do not have permission to change this document's editors")
if request.method == 'POST':
form = ChangeEditorsForm(request.POST)
if form.is_valid():
new_editors = form.cleaned_data['editors']
if set(new_editors) != set(previous_editors):
e = BofreqEditorDocEvent(type="changed_editors", doc=bofreq, rev=bofreq.rev, by=request.user.person)
e.desc = f'Editors changed to {", ".join([p.name for p in new_editors])}'
e.save()
e.editors.set(new_editors)
bofreq.save_with_history([e])
email_bofreq_editors_changed(request, bofreq, previous_editors)
return redirect("ietf.doc.views_doc.document_main", name=bofreq.name)
else:
init = { "editors" : previous_editors }
form = ChangeEditorsForm(initial=init)
titletext = bofreq.get_base_name()
return render(request, 'doc/bofreq/change_editors.html',
{'form': form,
'doc': bofreq,
'titletext' : titletext,
},
)
class ChangeResponsibleForm(forms.Form):
responsible = SearchablePersonsField(required=False)
def clean_responsible(self):
responsible = self.cleaned_data['responsible']
not_leadership = list()
for person in responsible:
if not has_role(person.user, ('Area Director', 'IAB')):
not_leadership.append(person)
if not_leadership:
raise forms.ValidationError('Only current IAB and IESG members are allowed. Please remove: '+', '.join([person.plain_name() for person in not_leadership]))
return responsible
@login_required
def change_responsible(request,name):
if not has_role(request.user,('Secretariat', 'Area Director', 'IAB')):
permission_denied(request,"You do not have permission to change this document's responsible leadership")
bofreq = get_object_or_404(Document, type="bofreq", name=name)
previous_responsible = bofreq_responsible(bofreq)
if request.method == 'POST':
form = ChangeResponsibleForm(request.POST)
if form.is_valid():
new_responsible = form.cleaned_data['responsible']
if set(new_responsible) != set(previous_responsible):
e = BofreqResponsibleDocEvent(type="changed_responsible", doc=bofreq, rev=bofreq.rev, by=request.user.person)
e.desc = f'Responsible leadership changed to {", ".join([p.name for p in new_responsible])}'
e.save()
e.responsible.set(new_responsible)
bofreq.save_with_history([e])
email_bofreq_responsible_changed(request, bofreq, previous_responsible)
return redirect("ietf.doc.views_doc.document_main", name=bofreq.name)
else:
init = { "responsible" : previous_responsible }
form = ChangeResponsibleForm(initial=init)
titletext = bofreq.get_base_name()
return render(request, 'doc/bofreq/change_responsible.html',
{'form': form,
'doc': bofreq,
'titletext' : titletext,
},
)
class ChangeTitleForm(forms.Form):
title = forms.CharField(max_length=255, label="Title", required=True)
@login_required
def edit_title(request, name):
bofreq = get_object_or_404(Document, type="bofreq", name=name)
editors = bofreq_editors(bofreq)
state_id = bofreq.get_state_slug('bofreq')
if not (has_role(request.user,('Secretariat', 'Area Director', 'IAB')) or (state_id=='proposed' and request.user.person in editors)):
permission_denied(request, "You do not have permission to edit this document's title")
if request.method == 'POST':
form = ChangeTitleForm(request.POST)
if form.is_valid():
bofreq.title = form.cleaned_data['title']
c = DocEvent(type="added_comment", doc=bofreq, rev=bofreq.rev, by=request.user.person)
c.desc = "Title changed to '%s'"%bofreq.title
c.save()
bofreq.save_with_history([c])
email_bofreq_title_changed(request, bofreq)
return redirect("ietf.doc.views_doc.document_main", name=bofreq.name)
else:
init = { "title" : bofreq.title }
form = ChangeTitleForm(initial=init)
titletext = bofreq.get_base_name()
return render(request, 'doc/change_title.html',
{'form': form,
'doc': bofreq,
'titletext' : titletext,
},
)
class ChangeStateForm(forms.Form):
new_state = forms.ModelChoiceField(State.objects.filter(type="bofreq", used=True), label="BOF Request State", empty_label=None, required=True)
comment = forms.CharField(widget=forms.Textarea, help_text="Optional comment for the state change history entry.", required=False, strip=False)
@role_required('Area Director', 'Secretariat', 'IAB')
def change_state(request, name, option=None):
bofreq = get_object_or_404(Document, type="bofreq", name=name)
login = request.user.person
if request.method == 'POST':
form = ChangeStateForm(request.POST)
if form.is_valid():
clean = form.cleaned_data
new_state = clean['new_state']
comment = clean['comment'].rstrip()
if comment:
c = DocEvent(type="added_comment", doc=bofreq, rev=bofreq.rev, by=login)
c.desc = comment
c.save()
prev_state = bofreq.get_state()
if new_state != prev_state:
bofreq.set_state(new_state)
events = []
events.append(add_state_change_event(bofreq, login, prev_state, new_state))
bofreq.save_with_history(events)
return redirect('ietf.doc.views_doc.document_main', name=bofreq.name)
else:
s = bofreq.get_state()
init = dict(new_state=s.pk if s else None)
form = ChangeStateForm(initial=init)
return render(request, 'doc/change_state.html',
dict(form=form,
doc=bofreq,
login=login,
help_url=urlreverse('ietf.doc.views_help.state_help', kwargs=dict(type="bofreq")),
))

View file

@ -40,6 +40,7 @@ import io
import json
import os
import re
import markdown
from urllib.parse import quote
@ -54,14 +55,15 @@ import debug # pyflakes:ignore
from ietf.doc.models import ( Document, DocAlias, DocHistory, DocEvent, BallotDocEvent, BallotType,
ConsensusDocEvent, NewRevisionDocEvent, TelechatDocEvent, WriteupDocEvent, IanaExpertDocEvent,
IESG_BALLOT_ACTIVE_STATES, STATUSCHANGE_RELATIONS, DocumentActionHolder, DocumentAuthor )
IESG_BALLOT_ACTIVE_STATES, STATUSCHANGE_RELATIONS, DocumentActionHolder, DocumentAuthor)
from ietf.doc.utils import (add_links_in_new_revision_events, augment_events_with_revision,
can_adopt_draft, can_unadopt_draft, get_chartering_type, get_tags_for_stream_id,
needed_ballot_positions, nice_consensus, prettify_std_name, update_telechat, has_same_ballot,
get_initial_notify, make_notify_changed_event, make_rev_history, default_consensus,
add_events_message_info, get_unicode_document_content, build_doc_meta_block,
augment_docs_and_user_with_user_info, irsg_needed_ballot_positions, add_action_holder_change_event,
build_doc_supermeta_block, build_file_urls, update_documentauthors )
build_doc_supermeta_block, build_file_urls, update_documentauthors)
from ietf.doc.utils_bofreq import bofreq_editors, bofreq_responsible
from ietf.group.models import Role, Group
from ietf.group.utils import can_manage_group_type, can_manage_materials, group_features_role_filter
from ietf.ietfauth.utils import ( has_role, is_authorized_in_doc_stream, user_is_person,
@ -526,6 +528,26 @@ def document_main(request, name, rev=None):
can_manage=can_manage,
))
if doc.type_id == "bofreq":
content = markdown.markdown(doc.text_or_error(),extensions=['extra'])
editors = bofreq_editors(doc)
responsible = bofreq_responsible(doc)
can_manage = has_role(request.user,['Secretariat', 'Area Director', 'IAB'])
editor_can_manage = doc.get_state_slug('bofreq')=='proposed' and request.user.is_authenticated and request.user.person in editors
return render(request, "doc/document_bofreq.html",
dict(doc=doc,
top=top,
revisions=revisions,
latest_rev=latest_rev,
content=content,
snapshot=snapshot,
can_manage=can_manage,
editors=editors,
responsible=responsible,
editor_can_manage=editor_can_manage,
))
if doc.type_id == "conflrev":
filename = "%s-%s.txt" % (doc.canonical_name(), doc.rev)
pathname = os.path.join(settings.CONFLICT_REVIEW_PATH,filename)

View file

@ -17,6 +17,7 @@ def state_help(request, type):
"charter": ("charter", "Charter States"),
"conflict-review": ("conflrev", "Conflict Review States"),
"status-change": ("statchg", "RFC Status Change States"),
"bofreq": ("bofreq", "BOF Request States"),
}.get(type, (None, None))
state_type = get_object_or_404(StateType, slug=slug)

View file

@ -188,11 +188,11 @@ class GroupForm(forms.Form):
if existing and existing.type_id == self.group_type:
if existing.state_id == "bof":
#insert_confirm_field(label="Turn BoF %s into proposed %s and start chartering it" % (existing.acronym, existing.type.name), initial=True)
#insert_confirm_field(label="Turn BOF %s into proposed %s and start chartering it" % (existing.acronym, existing.type.name), initial=True)
if confirmed:
return acronym
else:
raise forms.ValidationError("Warning: Acronym used for an existing BoF (%s)." % existing.acronym)
raise forms.ValidationError("Warning: Acronym used for an existing BOF (%s)." % existing.acronym)
else:
#insert_confirm_field(label="Set state of %s %s to proposed and start chartering it" % (existing.acronym, existing.type.name), initial=False)
if confirmed:
@ -265,7 +265,7 @@ class GroupForm(forms.Form):
state = cleaned_data.get('state', None)
parent = cleaned_data.get('parent', None)
if state and (state.slug in ['bof', ] and 'parent' in self.fields and not parent):
raise forms.ValidationError("You requested the creation of a BoF, but specified no parent area. A parent is required when creating a bof.")
raise forms.ValidationError("You requested the creation of a BOF, but specified no parent area. A parent is required when creating a bof.")
return cleaned_data

View file

@ -273,8 +273,6 @@ class GroupPagesTests(TestCase):
def test_group_about(self):
RoleFactory(group=Group.objects.get(acronym='iab'),name_id='member',person=PersonFactory(user__username='iab-member'))
interesting_users = [ 'plain','iana','iab-chair','irtf-chair', 'marschairman', 'teamchairman','ad', 'iab-member', 'secretary', ]
can_edit = {
@ -563,7 +561,7 @@ class GroupEditTests(TestCase):
q = PyQuery(r.content)
self.assertTrue(len(q('form .has-error')) > 0)
# try elevating BoF to WG
# try elevating BOF to WG
group.state_id = "bof"
group.save()

View file

@ -25,6 +25,7 @@ import debug # pyflakes:ignore
from ietf.group.models import Role, GroupFeatures
from ietf.person.models import Person
from ietf.doc.utils_bofreq import bofreq_editors
def user_is_person(user, person):
"""Test whether user is associated with person."""
@ -194,6 +195,9 @@ def is_individual_draft_author(user, doc):
if not user.is_authenticated:
return False
if not doc.type_id=='draft':
return False
if not doc.group.type_id == "individ" :
return False
@ -205,6 +209,13 @@ def is_individual_draft_author(user, doc):
return False
def is_bofreq_editor(user, doc):
if not user.is_authenticated:
return False
if not doc.type_id=='bofreq':
return False
return user.person in bofreq_editors(doc)
def openid_userinfo(claims, user):
# Populate claims dict.
person = get_object_or_404(Person, user=user)

View file

@ -0,0 +1,50 @@
# Copyright The IETF Trust 2021 All Rights Reserved
# Generated by Django 2.2.23 on 2021-05-26 07:52
from django.db import migrations
def forward(apps, schema_editor):
MailTrigger = apps.get_model('mailtrigger', 'MailTrigger')
Recipient = apps.get_model('mailtrigger', 'Recipient')
Recipient.objects.create(slug='bofreq_editors',desc='BOF request editors',template='')
Recipient.objects.create(slug='bofreq_previous_editors',desc='Editors of the prior version of a BOF request',
template='{% for editor in previous_editors %}{{editor.email_address}}{% if not forloop.last %},{% endif %}{% endfor %}')
Recipient.objects.create(slug='bofreq_responsible',desc='BOF request responsible leadership',template='')
Recipient.objects.create(slug='bofreq_previous_responsible',desc='BOF request responsible leadership before change', template='')
mt = MailTrigger.objects.create(slug='bofreq_title_changed',desc='Recipients when the title of a BOF proposal is changed.')
mt.to.set(Recipient.objects.filter(slug__in=['bofreq_responsible', 'bofreq_editors', 'doc_notify']))
mt = MailTrigger.objects.create(slug='bofreq_editors_changed',desc='Recipients when the editors of a BOF proposal are changed.')
mt.to.set(Recipient.objects.filter(slug__in=['bofreq_responsible', 'bofreq_editors', 'bofreq_previous_editors', 'doc_notify']))
mt = MailTrigger.objects.create(slug='bofreq_responsible_changed',desc='Recipients when the responsible leadership of a BOF proposal are changed.')
mt.to.set(Recipient.objects.filter(slug__in=['bofreq_responsible', 'bofreq_editors', 'bofreq_previous_responsible', 'doc_notify']))
mt = MailTrigger.objects.create(slug='bofreq_new_revision', desc='Recipients when a new revision of a BOF request is uploaded.')
mt.to.set(Recipient.objects.filter(slug__in=['bofreq_responsible', 'bofreq_editors', 'doc_notify']))
for recipient in Recipient.objects.filter(slug__in=['bofreq_responsible','bofreq_editors']):
MailTrigger.objects.get(slug='doc_state_edited').to.add(recipient)
def reverse(apps, schema_editor):
MailTrigger = apps.get_model('mailtrigger', 'MailTrigger')
Recipient = apps.get_model('mailtrigger', 'Recipient')
for recipient in Recipient.objects.filter(slug__in=['bofreq_responsible','bofreq_editors']):
MailTrigger.objects.get(slug='doc_state_edited').to.remove(recipient)
MailTrigger.objects.filter(slug__in=('bofreq_title_changed', 'bofreq_editors_changed', 'bofreq_new_revision', 'bofreq_responsible_changed')).delete()
Recipient.objects.filter(slug__in=('bofreq_editors', 'bofreq_previous_editors')).delete()
Recipient.objects.filter(slug__in=('bofreq_responsible', 'bofreq_previous_responsible')).delete()
class Migration(migrations.Migration):
dependencies = [
('mailtrigger', '0022_add_doc_external_resource_change_requested'),
]
operations = [
migrations.RunPython(forward,reverse)
]

View file

@ -6,6 +6,8 @@ from django.db import models
from django.template import Template, Context
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.person.models import Email, Alias
@ -392,3 +394,36 @@ class Recipient(models.Model):
def gather_yang_doctors_secretaries(self, **kwargs):
return self.gather_group_secretaries(group=Group.objects.get(acronym='yangdoctors'))
def gather_bofreq_editors(self, **kwargs):
addrs = []
if 'doc' in kwargs:
bofreq = kwargs['doc']
editors = bofreq_editors(bofreq)
addrs.extend([editor.email_address() for editor in editors])
return addrs
def gather_bofreq_responsible(self, **kwargs):
addrs = []
if 'doc' in kwargs:
bofreq = kwargs['doc']
responsible = bofreq_responsible(bofreq)
if responsible:
addrs.extend([leader.email_address() for leader in responsible])
else:
addrs.extend(Recipient.objects.get(slug='iab').gather(**{}))
addrs.extend(Recipient.objects.get(slug='iesg').gather(**{}))
return addrs
def gather_bofreq_previous_responsible(self, **kwargs):
addrs = []
previous_responsible = None
if previous_responsible in kwargs:
previous_responsible = kwargs['previous_responsible']
if previous_responsible:
addrs = [p.email_address() for p in previous_responsible]
else:
addrs.extend(Recipient.objects.get(slug='iab').gather(**{}))
addrs.extend(Recipient.objects.get(slug='iesg').gather(**{}))
return addrs

View file

@ -68,8 +68,10 @@ def gather_relevant_expansions(**kwargs):
doc = kwargs['doc']
# PEY: does this need to include irsg_ballot_saved as well?
relevant.update(['doc_state_edited','doc_telechat_details_changed','ballot_deferred','iesg_ballot_saved'])
relevant.add('doc_state_edited')
if not doc.type_id in ['bofreq',]:
relevant.update(['doc_telechat_details_changed','ballot_deferred','iesg_ballot_saved'])
if doc.type_id in ['draft','statchg']:
relevant.update(starts_with('last_call_'))
@ -91,6 +93,9 @@ def gather_relevant_expansions(**kwargs):
if doc.type_id == 'charter':
relevant.update(['charter_external_review','ballot_approved_charter'])
if doc.type_id == 'bofreq':
relevant.update(starts_with('bofreq'))
if 'group' in kwargs:
relevant.update(starts_with('group_'))

View file

@ -366,7 +366,7 @@ class Command(BaseCommand):
attendees=100,
agenda_note="",
requested_duration=datetime.timedelta(seconds=7200), # 2:00:00
comments="""Must not conflict with Transport Area BoFs. """, # this is implicit
comments="""Must not conflict with Transport Area BOFs. """, # this is implicit
remote_instructions="",
)
## session for tsvwg ##
@ -377,7 +377,7 @@ class Command(BaseCommand):
attendees=100,
agenda_note="",
requested_duration=datetime.timedelta(seconds=7200), # 2:00:00
comments="""Must not conflict with Transport Area BoFs. """, # this is implicit
comments="""Must not conflict with Transport Area BOFs. """, # this is implicit
remote_instructions="",
)
c = Constraint.objects.create(meeting=m, source=s.group, name_id='conflict', target_id=1665, ) # intarea
@ -440,7 +440,7 @@ class Command(BaseCommand):
attendees=80,
agenda_note="Joint with ARTAREA",
requested_duration=datetime.timedelta(seconds=7200), # 2:00:00
comments=""" and avoid the same kind of conflicts with other area meetings and any Bofs and potential new ART WGs.""", # this is implicit
comments=""" and avoid the same kind of conflicts with other area meetings and any BOFs and potential new ART WGs.""", # this is implicit
remote_instructions="",
)
s.joint_with_groups.set(Group.objects.filter(acronym='artarea'))
@ -2598,7 +2598,7 @@ class Command(BaseCommand):
attendees=50,
agenda_note="",
requested_duration=datetime.timedelta(seconds=5400), # 1:30:00
comments="""Please avoid collision with any Sec and IoT-related BoFs.""",
comments="""Please avoid collision with any Sec and IoT-related BOFs.""",
remote_instructions="",
)
c = Constraint.objects.create(meeting=m, source=s.group, name_id='conflict', target_id=1187, ) # saag

View file

@ -705,24 +705,24 @@ class Session(object):
for other in overlapping_sessions:
if not other:
continue
# BoFs cannot conflict with PRGs
# BOFs cannot conflict with PRGs
if self.is_bof and other.is_prg:
violations.append('{}: BoF overlaps with PRG: {}'
violations.append('{}: BOF overlaps with PRG: {}'
.format(self.group, other.group))
cost += self.business_constraint_costs['bof_overlapping_prg']
# BoFs cannot conflict with any other BoFs
# BOFs cannot conflict with any other BOFs
if self.is_bof and other.is_bof:
violations.append('{}: BoF overlaps with other BoF: {}'
violations.append('{}: BOF overlaps with other BOF: {}'
.format(self.group, other.group))
cost += self.business_constraint_costs['bof_overlapping_bof']
# BoFs cannot conflict with any other WGs in their area
# BOFs cannot conflict with any other WGs in their area
if self.is_bof and self.parent == other.parent:
violations.append('{}: BoF overlaps with other session from same area: {}'
violations.append('{}: BOF overlaps with other session from same area: {}'
.format(self.group, other.group))
cost += self.business_constraint_costs['bof_overlapping_area_wg']
# BoFs cannot conflict with any area-wide meetings (of any area)
# BOFs cannot conflict with any area-wide meetings (of any area)
if self.is_bof and other.is_area_meeting:
violations.append('{}: BoF overlaps with area meeting {}'
violations.append('{}: BOF overlaps with area meeting {}'
.format(self.group, other.group))
cost += self.business_constraint_costs['bof_overlapping_area_meeting']
# Area meetings cannot conflict with anything else in their area

View file

@ -9,22 +9,22 @@ def forward(apps, schema_editor):
BusinessConstraint = apps.get_model("meeting", "BusinessConstraint")
BusinessConstraint.objects.create(
slug="bof_overlapping_prg",
name="BoFs cannot conflict with PRGs",
name="BOFs cannot conflict with PRGs",
penalty=100000,
)
BusinessConstraint.objects.create(
slug="bof_overlapping_bof",
name="BoFs cannot conflict with any other BoFs",
name="BOFs cannot conflict with any other BOFs",
penalty=100000,
)
BusinessConstraint.objects.create(
slug="bof_overlapping_area_wg",
name="BoFs cannot conflict with any other WGs in their area",
name="BOFs cannot conflict with any other WGs in their area",
penalty=100000,
)
BusinessConstraint.objects.create(
slug="bof_overlapping_area_meeting",
name="BoFs cannot conflict with any area-wide meetings (of any area)",
name="BOFs cannot conflict with any area-wide meetings (of any area)",
penalty=10000,
)
BusinessConstraint.objects.create(

View file

@ -752,7 +752,7 @@ class MeetingTests(TestCase):
# Should be a 'non-area events' link showing appropriate types
non_area_labels = [
'BoF', 'EDU', 'Hackathon', 'IEPG', 'IESG', 'IETF', 'Plenary', 'Secretariat', 'Tools',
'BOF', 'EDU', 'Hackathon', 'IEPG', 'IESG', 'IETF', 'Plenary', 'Secretariat', 'Tools',
]
self.assertIn('%s?show=%s' % (ical_url, ','.join(non_area_labels).lower()), content)
@ -1029,7 +1029,7 @@ class EditMeetingScheduleTests(TestCase):
self.assertEqual(time_labels, time_header_labels)
def test_bof_session_tag(self):
"""Sessions for BoF groups should be marked as such"""
"""Sessions for BOF groups should be marked as such"""
meeting = MeetingFactory(type_id='ietf')
non_bof_session = SessionFactory(meeting=meeting)
@ -1044,13 +1044,13 @@ class EditMeetingScheduleTests(TestCase):
q = PyQuery(r.content)
self.assertEqual(len(q('#session{} .bof-tag'.format(non_bof_session.pk))), 0,
'Non-BoF session should not be tagged as a BoF session')
'Non-BOF session should not be tagged as a BOF session')
bof_tags = q('#session{} .bof-tag'.format(bof_session.pk))
self.assertEqual(len(bof_tags), 1,
'BoF session should have one BoF session tag')
self.assertIn('BoF', bof_tags.eq(0).text(),
'BoF tag should contain text "BoF"')
'BOF session should have one BOF session tag')
self.assertIn('BOF', bof_tags.eq(0).text(),
'BOF tag should contain text "BOF"')
def _setup_for_swap_timeslots(self):
"""Create a meeting, rooms, and schedule for swap_timeslots testing

View file

@ -1552,7 +1552,7 @@ def prepare_filter_keywords(tagged_assignments, group_parents):
# Keywords that should appear in 'non-area' column
non_area_labels = [
'BoF', 'EDU', 'Hackathon', 'IEPG', 'IESG', 'IETF', 'Plenary', 'Secretariat', 'Tools',
'BOF', 'EDU', 'Hackathon', 'IEPG', 'IESG', 'IETF', 'Plenary', 'Secretariat', 'Tools',
]
# Remove any unused non-area keywords
non_area_filters = [
@ -4102,7 +4102,7 @@ def request_minutes(request, num=None):
body = render_to_string('meeting/request_minutes.txt', body_context)
initial = {'to': 'wgchairs@ietf.org',
'cc': 'irsg@irtf.org',
'subject': 'Request for IETF WG and Bof Session Minutes',
'subject': 'Request for IETF WG and BOF Session Minutes',
'body': body,
}
form = RequestMinutesForm(initial=initial)

View file

@ -2305,6 +2305,76 @@
"model": "doc.state",
"pk": 157
},
{
"fields": {
"desc": "The BOF request is proposed",
"name": "Proposed",
"next_states": [
159,
160,
161,
162
],
"order": 0,
"slug": "proposed",
"type": "bofreq",
"used": true
},
"model": "doc.state",
"pk": 158
},
{
"fields": {
"desc": "The BOF request is approved",
"name": "Approved",
"next_states": [],
"order": 1,
"slug": "approved",
"type": "bofreq",
"used": true
},
"model": "doc.state",
"pk": 159
},
{
"fields": {
"desc": "The BOF request is declined",
"name": "Declined",
"next_states": [],
"order": 2,
"slug": "declined",
"type": "bofreq",
"used": true
},
"model": "doc.state",
"pk": 160
},
{
"fields": {
"desc": "The BOF request is proposed",
"name": "Replaced",
"next_states": [],
"order": 3,
"slug": "replaced",
"type": "bofreq",
"used": true
},
"model": "doc.state",
"pk": 161
},
{
"fields": {
"desc": "The BOF request is abandoned",
"name": "Abandoned",
"next_states": [],
"order": 4,
"slug": "abandoned",
"type": "bofreq",
"used": true
},
"model": "doc.state",
"pk": 162
},
{
"fields": {
"label": "State"
@ -2319,6 +2389,13 @@
"model": "doc.statetype",
"pk": "bluesheets"
},
{
"fields": {
"label": "BOF Request State"
},
"model": "doc.statetype",
"pk": "bofreq"
},
{
"fields": {
"label": "State"
@ -2625,20 +2702,20 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\",\n \"secr\"\n]",
"agenda_type": null,
"agenda_type": "ad",
"create_wiki": true,
"custom_group_roles": true,
"customize_workflow": false,
"default_parent": "",
"default_tab": "ietf.group.views.group_about",
"default_used_roles": "[\n \"ad\",\n \"chair\",\n \"reviewer\",\n \"secr\"\n]",
"docman_roles": "[]",
"docman_roles": "[\n \"chair\"\n]",
"groupman_authroles": "[\n \"Secretariat\"\n]",
"groupman_roles": "[\n \"ad\",\n \"secr\"\n]",
"has_chartering_process": false,
"has_default_jabber": false,
"has_documents": false,
"has_meetings": false,
"has_meetings": true,
"has_milestones": false,
"has_nonsession_materials": false,
"has_reviews": false,
@ -3451,6 +3528,60 @@
"model": "mailtrigger.mailtrigger",
"pk": "ballot_saved"
},
{
"fields": {
"cc": [],
"desc": "Recipients when the editors of a BOF proposal are changed.",
"to": [
"bofreq_editors",
"bofreq_previous_editors",
"bofreq_responsible",
"doc_notify"
]
},
"model": "mailtrigger.mailtrigger",
"pk": "bofreq_editors_changed"
},
{
"fields": {
"cc": [],
"desc": "Recipients when a new revision of a BOF request is uploaded.",
"to": [
"bofreq_editors",
"bofreq_responsible",
"doc_notify"
]
},
"model": "mailtrigger.mailtrigger",
"pk": "bofreq_new_revision"
},
{
"fields": {
"cc": [],
"desc": "Recipients when the responsible leadership of a BOF proposal are changed.",
"to": [
"bofreq_editors",
"bofreq_previous_responsible",
"bofreq_responsible",
"doc_notify"
]
},
"model": "mailtrigger.mailtrigger",
"pk": "bofreq_responsible_changed"
},
{
"fields": {
"cc": [],
"desc": "Recipients when the title of a BOF proposal is changed.",
"to": [
"bofreq_editors",
"bofreq_responsible",
"doc_notify"
]
},
"model": "mailtrigger.mailtrigger",
"pk": "bofreq_title_changed"
},
{
"fields": {
"cc": [
@ -3731,6 +3862,8 @@
"cc": [],
"desc": "Recipients when a document's state is manually edited",
"to": [
"bofreq_editors",
"bofreq_responsible",
"doc_ad",
"doc_affecteddoc_authors",
"doc_affecteddoc_group_chairs",
@ -5104,6 +5237,38 @@
"model": "mailtrigger.mailtrigger",
"pk": "sub_replaced_doc_director_approval_requested"
},
{
"fields": {
"desc": "BOF request editors",
"template": ""
},
"model": "mailtrigger.recipient",
"pk": "bofreq_editors"
},
{
"fields": {
"desc": "Editors of the prior version of a BOF request",
"template": "{% for editor in previous_editors %}{{editor.email_address}}{% if not forloop.last %},{% endif %}{% endfor %}"
},
"model": "mailtrigger.recipient",
"pk": "bofreq_previous_editors"
},
{
"fields": {
"desc": "BOF request responsible leadership before change",
"template": ""
},
"model": "mailtrigger.recipient",
"pk": "bofreq_previous_responsible"
},
{
"fields": {
"desc": "BOF request responsible leadership",
"template": ""
},
"model": "mailtrigger.recipient",
"pk": "bofreq_responsible"
},
{
"fields": {
"desc": "The person providing a comment to nomcom",
@ -9751,6 +9916,17 @@
"model": "name.doctypename",
"pk": "bluesheets"
},
{
"fields": {
"desc": "",
"name": "BOF Request",
"order": 0,
"prefix": "bofreq",
"used": true
},
"model": "name.doctypename",
"pk": "bofreq"
},
{
"fields": {
"desc": "",
@ -12052,6 +12228,16 @@
"model": "name.rolename",
"pk": "delegate"
},
{
"fields": {
"desc": "",
"name": "Director of Development",
"order": 0,
"used": true
},
"model": "name.rolename",
"pk": "devdir"
},
{
"fields": {
"desc": "",
@ -15299,7 +15485,7 @@
"fields": {
"command": "xym",
"switch": "--version",
"time": "2021-06-08T00:12:46.324",
"time": "2021-07-13T00:12:25.184",
"used": true,
"version": "xym 0.5"
},
@ -15310,9 +15496,9 @@
"fields": {
"command": "pyang",
"switch": "--version",
"time": "2021-06-08T00:12:48.137",
"time": "2021-07-13T00:12:26.721",
"used": true,
"version": "pyang 2.4.0"
"version": "pyang 2.5.0"
},
"model": "utils.versioninfo",
"pk": 2
@ -15321,7 +15507,7 @@
"fields": {
"command": "yanglint",
"switch": "--version",
"time": "2021-06-08T00:12:48.465",
"time": "2021-07-13T00:12:27.015",
"used": true,
"version": "yanglint SO 1.6.7"
},
@ -15332,9 +15518,9 @@
"fields": {
"command": "xml2rfc",
"switch": "--version",
"time": "2021-06-08T00:12:51.318",
"time": "2021-07-13T00:12:29.814",
"used": true,
"version": "xml2rfc 3.8.0"
"version": "xml2rfc 3.9.1"
},
"model": "utils.versioninfo",
"pk": 4

View file

@ -0,0 +1,23 @@
# Copyright The IETF Trust 2021 All Rights Reserved
# Generated by Django 2.2.23 on 2021-05-21 12:48
from django.db import migrations
def forward(apps,schema_editor):
DocTypeName = apps.get_model('name','DocTypeName')
DocTypeName.objects.create(prefix='bofreq', slug='bofreq', name="BOF Request", desc="", used=True, order=0)
def reverse(apps,schema_editor):
DocTypeName = apps.get_model('name','DocTypeName')
DocTypeName.objects.filter(slug='bofreq').delete()
class Migration(migrations.Migration):
dependencies = [
('name', '0026_add_conflict_constraintnames'),
]
operations = [
migrations.RunPython(forward, reverse)
]

View file

@ -19,7 +19,7 @@ TO_LIST = ('IETF Announcement List <ietf-announce@ietf.org>',
'RFP Announcement List <rfp-announce@ietf.org>',
'The IESG <iesg@ietf.org>',
'Working Group Chairs <wgchairs@ietf.org>',
'BoF Chairs <bofchairs@ietf.org>',
'BOF Chairs <bofchairs@ietf.org>',
'Other...')
# ---------------------------------------------

View file

@ -645,6 +645,7 @@ DATETIME_FORMAT = "Y-m-d H:i T"
# regex can reasonably be expected to be a unique one-off.
URL_REGEXPS = {
"acronym": r"(?P<acronym>[-a-z0-9]+)",
"bofreq": r"(?P<name>bofreq-[-a-z0-9]+)",
"charter": r"(?P<name>charter-[-a-z0-9]+)",
"date": r"(?P<date>\d{4}-\d{2}-\d{2})",
"name": r"(?P<name>[A-Za-z0-9._+-]+?)",
@ -663,6 +664,7 @@ INTERNET_DRAFT_PATH = '/a/ietfdata/doc/draft/repository'
INTERNET_DRAFT_PDF_PATH = '/a/www/ietf-datatracker/pdf/'
RFC_PATH = '/a/www/ietf-ftp/rfc/'
CHARTER_PATH = '/a/ietfdata/doc/charter/'
BOFREQ_PATH = '/a/ietfdata/doc/bofreq/'
CONFLICT_REVIEW_PATH = '/a/ietfdata/doc/conflict-review'
STATUS_CHANGE_PATH = '/a/ietfdata/doc/status-change'
AGENDA_PATH = '/a/www/www6s/proceedings/'

View file

@ -0,0 +1,26 @@
$(document).ready(function () {
var form = $("form.upload-content");
// review submission selection
form.find("[name=bofreq_submission]").on("click change", function () {
var val = form.find("[name=bofreq_submission]:checked").val();
var shouldBeVisible = {
"enter": ['[name="bofreq_content"]'],
"upload": ['[name="bofreq_file"]'],
};
for (var v in shouldBeVisible) {
for (var i in shouldBeVisible[v]) {
var selector = shouldBeVisible[v][i];
var row = form.find(selector);
if (!row.is(".form-group"))
row = row.closest(".form-group");
if ($.inArray(selector, shouldBeVisible[val]) != -1)
row.show();
else
row.hide();
}
}
}).trigger("change");
});

View file

@ -27,6 +27,7 @@
<li {%if flavor == "top" %}class="dropdown-header hidden-xs"{% else %}class="nav-header"{% endif %}>New work</li>
<li><a href="{% url "ietf.group.views.chartering_groups" %}">Chartering groups</a></li>
<li><a href="{% url "ietf.group.views.bofs" group_type="wg" %}">BOFs</a></li>
<li><a href="{% url "ietf.doc.views_bofreq.bof_requests" %}">BOF Requests</a></li>
{% if flavor == "top" %}<li class="divider hidden-xs"></li>{% endif %}
<li {%if flavor == "top" %}class="dropdown-header hidden-xs"{% else %}class="nav-header"{% endif %}>Other groups</li>

View file

@ -0,0 +1,46 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2021 All Rights Reserved #}
{% load origin %}
{% load person_filters %}
{% block title %}BOF Requests{% endblock %}
{% block content %}
{% origin %}
<h1>BOF Requests</h1>
{% if request.user.is_authenticated %}
<div class="buttonlist">
<a id="start_button" class="btn btn-primary" href="{% url 'ietf.doc.views_bofreq.new_bof_request' %}">Start New BOF Request</a>
</div>
{% endif %}
{% if not reqs %}
<p>There are currently no BOF Requests</p>
{% else %}
{% regroup reqs by get_state_slug as grouped_reqs %}
{% for req_group in grouped_reqs %}
<div class="panel panel-default">
<div class="panel-heading">{{req_group.grouper|capfirst}} BOF Requests</div>
<div class="panel-body">
<table id="bofreqs-{{req_group.grouper}}" class="table table-condensed table-striped tablesorter">
<thead>
<tr><th class="col-sm-4">Name</th><th class="col-sm-1">Date</th><th>Title</th><th>Responsible</th><th>Editors</th></tr>
</thead>
<tbody>
{% for req in req_group.list %}
<tr>
<td><a href={% url 'ietf.doc.views_doc.document_main' name=req.name %}>{{req.name}}-{{req.rev}}</a></td>
<td>{{req.latest_revision_event.time|date:"Y-m-d"}}</td>
<td>{{req.title}}</td>
<td>{% for person in req.responsible %}{% person_link person %}{% if not forloop.last %}, {% endif %}{% endfor %}</td>
<td>{% for person in req.editors %}{% person_link person %}{% if not forloop.last %}, {% endif %}{% endfor %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
{% endif %}
{% endblock %}

View file

@ -0,0 +1,28 @@
# Name: Exact MPLS Edges (EXAMPLE) (There's an acronym for anything if you really want one ;-)
## Description
Replace this with a few paragraphs describing the BOF request.
Fill in the details below. Keep items in the order they appear here.
## Required Details
- Status: (not) WG Forming
- Responsible AD: name
- BOF proponents: name <email>, name <email> (1-3 people - who are requesting and coordinating discussion for proposal)
- BOF chairs: TBD
- Number of people expected to attend: 100
- Length of session (1 or 2 hours): 2 hours
- Conflicts (whole Areas and/or WGs)
- Chair Conflicts: TBD
- Technology Overlap: TBD
- Key Participant Conflict: TBD
## Agenda
- Items, drafts, speakers, timing
- Or a URL
## Links to the mailing list, draft charter if any, relevant Internet-Drafts, etc.
- Mailing List: https://www.ietf.org/mailman/listinfo/example
- Draft charter: https://datatracker.ietf.org/doc/charter-ietf-EXAMPLE/
- Relevant drafts:
- Use Cases:
- https://datatracker.ietf.org/html/draft-blah-uses
- Solutions
- https://datatracker.ietf.org/html/draft-blah-soln

View file

@ -0,0 +1,30 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2021, All Rights Reserved #}
{% load origin %}
{% load bootstrap3 %}
{% block title %}Change editors for {{doc.name}}{% endblock %}
{% block pagehead %}
{{ form.media.css}}
{% endblock %}
{% block content %}
{% origin %}
<h1>Change editors<br><small>{{ titletext }}</small></h1>
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<button type="submit" class="btn btn-primary">Submit</button>
<a class="btn btn-default pull-right" href="{% url "ietf.doc.views_doc.document_main" name=doc.canonical_name %}">Back</a>
{% endbuttons %}
</form>
{% endblock %}
{% block js %}
{{ form.media.js }}
{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2021, All Rights Reserved #}
{% load origin %}
{% load bootstrap3 %}
{% block title %}Change responsible leadership for {{doc.name}}{% endblock %}
{% block pagehead %}
{{ form.media.css}}
{% endblock %}
{% block content %}
{% origin %}
<h1>Change Responsible Leadership<br><small>{{ titletext }}</small></h1>
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<button type="submit" class="btn btn-primary">Submit</button>
<a class="btn btn-default pull-right" href="{% url "ietf.doc.views_doc.document_main" name=doc.canonical_name %}">Back</a>
{% endbuttons %}
</form>
{% endblock %}
{% block js %}
{{ form.media.js }}
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2021, All Rights Reserved #}
{% load origin bootstrap3 static %}
{% block title %}Start a new BOF Request{% endblock %}
{% block content %}
{% origin %}
<h1>Start a new BOF Request</h1>
<p>Choose a short descriptive title for your request. Take time to choose a good initial title - it will be used to make the filename for your request's content. The title can be changed later, but the filename will not change.</p>
<p>For example, a request with a title of "A new important bit" will be saved as "bofreq-a-new-important-bit-00.md".</p>
<form class="upload-content form-horizontal" method="post" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form form layout="horizontal" %}
{% buttons %}
<a class="btn btn-default" href="{{ doc.get_absolute_url }}">Cancel</a>
<button type="submit" class="btn btn-primary">Submit</button>
{% endbuttons %}
</form>
{% endblock %}
{% block js %}
<script src="{% static 'ietf/js/upload_bofreq.js' %}"></script>
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2021, All Rights Reserved #}
{% load origin bootstrap3 static %}
{% block title %}Upload new revision: {{doc.name}}{% endblock %}
{% block content %}
{% origin %}
<h1>Upload New Revision<br>
<small>{{ doc.name }}</small>
</h1>
<form class="upload-content form-horizontal" method="post" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form form layout="horizontal" %}
{% buttons %}
<a class="btn btn-default" href="{{ doc.get_absolute_url }}">Cancel</a>
<button type="submit" class="btn btn-primary">Submit</button>
{% endbuttons %}
</form>
{% endblock %}
{% block js %}
<script src="{% static 'ietf/js/upload_bofreq.js' %}"></script>
{% endblock %}

View file

@ -0,0 +1,180 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2021, All Rights Reserved #}
{% load origin %}
{% load static %}
{% load ietf_filters %}
{% load person_filters %}
{% block pagehead %}
<script src="{% static 'd3/d3.min.js' %}"></script>
<script src="{% static 'jquery/jquery.min.js' %}"></script>
<script src="{% static 'ietf/js/document_timeline.js' %}"></script>
{% endblock %}
{% block title %}{{ doc.title }}{% endblock %}
{% block content %}
{% origin %}
{{ top|safe }}
{% include "doc/revisions_list.html" %}
<div id="timeline"></div>
<table class="table table-condensed">
<thead id="message-row">
<tr>
{% if doc.rev != latest_rev %}
<th colspan="4" class="alert-warning">The information below is for an older version of this BOF request</th>
{% else %}
<th colspan="4"></th>
{% endif %}
</tr>
</thead>
<tbody class="meta">
<tr>
<th>Document</th>
<th>Type</th>
<td class="edit"></td>
<td>
{{doc.get_state.slug|capfirst}} BOF request
{% if snapshot %}
<span class="label label-warning">Snapshot</span>
{% endif %}
</td>
</tr>
<tr>
<td></td>
<th>Title</th>
<td class="edit">
{% if not snapshot %}
{% if editor_can_manage or can_manage %}
{% doc_edit_button 'ietf.doc.views_bofreq.edit_title' name=doc.name %}
{% endif %}
{% endif %}
</td>
<td>{{ doc.title }}</td>
</tr>
<tr>
<td></td>
<th>Last updated</th>
<td class="edit"></td>
<td>{{ doc.time|date:"Y-m-d" }}</td>
</tr>
<tr>
<td></td>
<th><a href="{% url 'ietf.doc.views_help.state_help' type='bofreq' %}">State</a></th>
<td class="edit">
{% if not snapshot and can_manage %}
{% doc_edit_button 'ietf.doc.views_bofreq.change_state' name=doc.name %}
{% endif %}
</td>
<td>
{% if doc.get_state %}
<span title="{{ doc.get_state.desc }}">{{ doc.get_state.name }}</span>
{% else %}
No document state
{% endif %}
</td>
</tr>
<tr id="editors">
<td></td>
<th>Editor{{editors|pluralize}}</th>
<td class="edit">
{% if not snapshot %}
{% if editor_can_manage or can_manage %}
{% doc_edit_button 'ietf.doc.views_bofreq.change_editors' name=doc.name %}
{% endif %}
{% endif %}
</td>
<td>
{% for editor in editors %}
{% person_link editor %}{% if not forloop.last %}, {% endif %}
{% endfor %}
</td>
</tr>
<tr id="responsible">
<td></td>
<th>Responsible Leadership</th>
<td class="edit">
{% if not snapshot %}
{% if can_manage %}
{% doc_edit_button 'ietf.doc.views_bofreq.change_responsible' name=doc.name %}
{% endif %}
{% endif %}
</td>
<td>
{% for leader in responsible %}
{% person_link leader %}{% if not forloop.last %}, {% endif %}
{% endfor %}
</td>
</tr>
{% with doc.docextresource_set.all as resources %}
{% if resources or editor_can_manage or can_manage %}
<tr>
<td></td>
<th>Additional Resources</th>
<td class="edit">
{% if editor_can_manage or can_manage %}
<a class="btn btn-default btn-xs" href="{% url 'ietf.doc.views_draft.edit_doc_extresources' name=doc.name %}">Edit</a>
{% endif %}
</td>
<td>
{% if resources %}
<table class="col-md-12 col-sm-12 col-xs-12">
<tbody>
{% for resource in resources|dictsort:"display_name" %}
{% if resource.name.type.slug == 'url' or resource.name.type.slug == 'email' %}
<tr><td> - <a href="{{ resource.value }}" title="{{resource.name.name}}">{% firstof resource.display_name resource.name.name %}</a></td></tr>
{# Maybe make how a resource displays itself a method on the class so templates aren't doing this switching #}
{% else %}
<tr><td> - <span title="{{resource.name.name}}">{% firstof resource.display_name resource.name.name %}: {{resource.value}}</span></td></tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% endif %}
</td>
</tr>
{% endif %}
{% endwith %}
<tr>
<td></td>
<th>Send notices to</th>
<td class="edit">
{% if not snapshot %}
{% if can_manage %}
{% doc_edit_button 'ietf.doc.views_doc.edit_notify' name=doc.name %}
{% endif %}
{% endif %}
</td>
<td>
{{ doc.notify|default:"(None)" }}
</td>
</tr>
</tbody>
</table>
{% if not snapshot %}
{% if editor_can_manage or can_manage %}
<p id="change-request"><a class="btn btn-default" href="{% url 'ietf.doc.views_bofreq.submit' name=doc.name %}">Change BOF request text</a></p>
{% endif %}
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">{{doc.name}}-{{doc.rev}}</div>
<div class="panel-body">
{{ content|sanitize|safe }}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,6 @@
{% autoescape off %}{{request.user.person.name}} has changed the editors for {{bofreq.name}}-{{bofreq.rev}}
The previous editors were : {{previous_editors|join:", "}}
The new editors are : {{editors|join:", "}}
{% endautoescape %}

View file

@ -0,0 +1,3 @@
{% autoescape off %}{{request.user.person.name}} has uploaded {{bofreq.name}}-{{bofreq.rev}}
See {{settings.IDTRACKER_BASE_URL}}{{bofreq.get_absolute_url}}
{% endautoescape %}

View file

@ -0,0 +1,6 @@
{% autoescape off %}{{request.user.person.name}} has changed the responsible leadership for {{bofreq.name}}-{{bofreq.rev}}
The previous leaders were : {{previous_responsible|join:", "}}
The new leaders are : {{responsible|join:", "}}
{% endautoescape %}

View file

@ -0,0 +1,3 @@
{% autoescape off %}{{request.user.person.name}} has changed the title for {{bofreq.name}}-{{bofreq.rev}} to
"{{bofreq.title}}"
{% endautoescape %}

View file

@ -1,7 +1,7 @@
<div id="session{{ session.pk }}" class="session {% if not session.group.parent.scheduling_color %}untoggleable{% endif %} {% if session.parent_acronym %}parent-{{ session.parent_acronym }}{% endif %} {% if session.readonly %}readonly{% endif %}" style="width:{{ session.layout_width }}em;" data-duration="{{ session.requested_duration.total_seconds }}" {% if session.attendees != None %}data-attendees="{{ session.attendees }}"{% endif %}>
<div class="session-label {% if session.group and session.group.is_bof %}bof-session{% endif %}">
{{ session.scheduling_label }}
{% if session.group and session.group.is_bof %}<span class="bof-tag">BoF</span>{% endif %}
{% if session.group and session.group.is_bof %}<span class="bof-tag">BOF</span>{% endif %}
</div>
<div>
@ -32,7 +32,7 @@
{{ session.scheduling_label }}
&middot; {{ session.requested_duration_in_hours }}h
{% if session.group %}
&middot; {% if session.group.is_bof %}BoF{% else %}{{ session.group.type.name }}{% endif %}
&middot; {% if session.group.is_bof %}BOF{% else %}{{ session.group.type.name }}{% endif %}
{% endif %}
{% if session.attendees != None %}
&middot; {{ session.attendees }} <i class="fa fa-user-o"></i>

View file

@ -27,7 +27,7 @@
{% if d.name.slug == 'opensched' %}
To request a Working Group session, use the
<a href="{% url 'ietf.secr.sreq.views.main' %}">IETF Meeting Session Request Tool</a>.
If you are working on a BoF request, it is highly recommended
If you are working on a BOF request, it is highly recommended
to tell the IESG now by sending an email to
<a href="mailto:iesg@ietf.org">iesg@ietf.org</a> to get advance help with the request.
{% endif %}

View file

@ -9,7 +9,7 @@ DESCRIPTION:{{ d.name.desc }}{% if first and d.name.slug == 'openreg' or first a
Register here: https://www.ietf.org/how/meetings/register/{% endif %}{% if d.name.slug == 'opensched' %}\n
To request a Working Group session, use the IETF Meeting Session Request Tool:\n
{{ request.scheme }}://{{ request.get_host}}{% url 'ietf.secr.sreq.views.main' %}\n
If you are working on a BoF request, it is highly recommended to tell the IESG\n
If you are working on a BOF request, it is highly recommended to tell the IESG\n
now by sending an email to iesg@ietf.org to get advance help with the request.{% endif %}{% if d.name.slug == 'cutoffwgreq' %}\n
To request a Working Group session, use the IETF Meeting Session Request Tool:\n
{{ request.scheme }}://{{ request.get_host }}{% url 'ietf.secr.sreq.views.main' %}{% endif %}{% if d.name.slug == 'cutoffbofreq' %}\n

View file

@ -16,6 +16,6 @@ manual posting.
Groups that are missing minutes:{% for group in needs_minutes %}{% ifchanged group.parent %}
{{group.parent.name}}:{% endifchanged %}
{{ group.acronym | upper }}{% if group.state_id == 'bof' %} (BoF){% endif %}{% endfor %}
{{ group.acronym | upper }}{% if group.state_id == 'bof' %} (BOF){% endif %}{% endfor %}
{% endautoescape %}

View file

@ -75,6 +75,7 @@ def make_immutable_base_data():
iab = create_group(name="Internet Architecture Board", acronym="iab", type_id="ietf", parent=ietf)
create_person(iab, "chair")
create_person(iab, "member")
ise = create_group(name="Independent Submission Editor", acronym="ise", type_id="rfcedtyp")
create_person(ise, "chair")

View file

@ -36,6 +36,7 @@ jsonfield>=3.0 # for SubmissionCheck. This is https://github.com/bradjasper/dj
jwcrypto>=0.4.0 # for signed notifications
logging_tree>=1.8.1
lxml>=3.4.0,<5
markdown>=3.3.4
markdown2>=2.3.8
mock>=2.0.0
mypy>=0.782,<0.790 # Version requirements determined by django-stubs.