From f5a04263e5144e22f9e7857d8eadeb6668954eb5 Mon Sep 17 00:00:00 2001 From: Robert Sparks <rjsparks@nostrum.com> Date: Wed, 7 Jul 2021 17:49:35 +0000 Subject: [PATCH] Added the notion of responsible leadership. - Legacy-Id: 19202 --- ietf/doc/admin.py | 6 +- ietf/doc/factories.py | 23 +++- ietf/doc/mails.py | 20 +++- ietf/doc/migrations/0043_bofreq_docevents.py | 36 ++++++ .../migrations/0043_bofreqeditordocevent.py | 23 ---- ietf/doc/models.py | 7 +- ietf/doc/resources.py | 29 ++++- ietf/doc/tests_bofreq.py | 104 +++++++++++++++--- ietf/doc/urls_bofreq.py | 3 +- ietf/doc/utils_bofreq.py | 12 ++ ietf/doc/views_bofreq.py | 61 ++++++++-- ietf/doc/views_doc.py | 11 +- ietf/group/tests_info.py | 2 - .../migrations/0023_bofreq_triggers.py | 17 ++- ietf/mailtrigger/models.py | 30 ++++- ietf/name/fixtures/names.json | 36 +++++- ietf/templates/doc/bofreq/bof_requests.html | 51 +++++---- .../doc/bofreq/change_responsible.html | 30 +++++ ietf/templates/doc/document_bofreq.html | 18 ++- .../doc/mail/bofreq_responsible_changed.txt | 6 + 20 files changed, 432 insertions(+), 93 deletions(-) create mode 100644 ietf/doc/migrations/0043_bofreq_docevents.py delete mode 100644 ietf/doc/migrations/0043_bofreqeditordocevent.py create mode 100644 ietf/doc/utils_bofreq.py create mode 100644 ietf/templates/doc/bofreq/change_responsible.html create mode 100644 ietf/templates/doc/mail/bofreq_responsible_changed.txt diff --git a/ietf/doc/admin.py b/ietf/doc/admin.py index 6f4f30143..a8c29c5b8 100644 --- a/ietf/doc/admin.py +++ b/ietf/doc/admin.py @@ -12,7 +12,7 @@ from .models import (StateType, State, RelatedDocument, DocumentAuthor, Document TelechatDocEvent, BallotPositionDocEvent, ReviewRequestDocEvent, InitialReviewDocEvent, AddedMessageEvent, SubmissionDocEvent, DeletedEvent, EditedAuthorsDocEvent, DocumentURL, ReviewAssignmentDocEvent, IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource, DocumentActionHolder, - BofreqEditorDocEvent ) + BofreqEditorDocEvent, BofreqResponsibleDocEvent ) from ietf.utils.validators import validate_external_resource_value @@ -198,6 +198,10 @@ 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', ] diff --git a/ietf/doc/factories.py b/ietf/doc/factories.py index e37e8119d..699f972ea 100644 --- a/ietf/doc/factories.py +++ b/ietf/doc/factories.py @@ -13,9 +13,10 @@ from django.conf import settings from ietf.doc.models import ( Document, DocEvent, NewRevisionDocEvent, DocAlias, State, DocumentAuthor, StateDocEvent, BallotPositionDocEvent, BallotDocEvent, BallotType, IRSGBallotDocEvent, TelechatDocEvent, - DocumentActionHolder, BofreqEditorDocEvent ) + 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 @@ -401,12 +402,32 @@ class BofreqEditorDocEventFactory(DocEventFactory): 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): diff --git a/ietf/doc/mails.py b/ietf/doc/mails.py index fc9a2bd26..0f344f015 100644 --- a/ietf/doc/mails.py +++ b/ietf/doc/mails.py @@ -18,8 +18,9 @@ from ietf.doc.templatetags.mail_filters import std_level_prompt from ietf.utils import log 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, BofreqEditorDocEvent +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 @@ -699,14 +700,27 @@ def email_bofreq_title_changed(request, bofreq): 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.latest_event(BofreqEditorDocEvent).editors.all() + 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=editors, previous_editors=previous_editors), + 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): diff --git a/ietf/doc/migrations/0043_bofreq_docevents.py b/ietf/doc/migrations/0043_bofreq_docevents.py new file mode 100644 index 000000000..7a300426b --- /dev/null +++ b/ietf/doc/migrations/0043_bofreq_docevents.py @@ -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',), + ), + ] diff --git a/ietf/doc/migrations/0043_bofreqeditordocevent.py b/ietf/doc/migrations/0043_bofreqeditordocevent.py deleted file mode 100644 index e6c3b433a..000000000 --- a/ietf/doc/migrations/0043_bofreqeditordocevent.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 2.2.23 on 2021-05-25 12:02 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('person', '0018_auto_20201109_0439'), - ('doc', '0042_bofreq_states'), - ] - - operations = [ - 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',), - ), - ] diff --git a/ietf/doc/models.py b/ietf/doc/models.py index cab807c4d..cfcda14fb 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -1349,4 +1349,9 @@ class EditedAuthorsDocEvent(DocEvent): basis = models.CharField(help_text="What is the source or reasoning for the changes to the author list",max_length=255) class BofreqEditorDocEvent(DocEvent): - editors = models.ManyToManyField('person.Person',blank=True) \ No newline at end of file + """ 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) \ No newline at end of file diff --git a/ietf/doc/resources.py b/ietf/doc/resources.py index afd2d7949..99e26ac33 100644 --- a/ietf/doc/resources.py +++ b/ietf/doc/resources.py @@ -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, BofreqEditorDocEvent) + IanaExpertDocEvent, IRSGBallotDocEvent, DocExtResource, DocumentActionHolder, + BofreqEditorDocEvent,BofreqResponsibleDocEvent) from ietf.name.resources import BallotPositionNameResource, DocTypeNameResource class BallotTypeResource(ModelResource): @@ -832,3 +833,29 @@ class BofreqEditorDocEventResource(ModelResource): "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()) diff --git a/ietf/doc/tests_bofreq.py b/ietf/doc/tests_bofreq.py index 5c5a1ac45..de9cb60c3 100644 --- a/ietf/doc/tests_bofreq.py +++ b/ietf/doc/tests_bofreq.py @@ -11,14 +11,15 @@ from tempfile import NamedTemporaryFile from django.conf import settings from django.urls import reverse as urlreverse +from ietf.group.factories import RoleFactory from ietf.doc.factories import BofreqFactory, NewRevisionDocEventFactory -from ietf.doc.models import State, BofreqEditorDocEvent, Document, DocAlias, NewRevisionDocEvent +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): @@ -42,10 +43,12 @@ 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) reqs = BofreqFactory.create_batch(states.count()) - url = urlreverse('ietf.doc.views_bofreq.bof_requests') r = self.client.get(url) self.assertEqual(r.status_code, 200) q = PyQuery(r.content) @@ -57,6 +60,13 @@ This test section has some text. q = PyQuery(r.content) for state in states: self.assertEqual(len(q(f'#bofreqs-{state.slug} tbody tr')), 1) + 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): @@ -67,7 +77,8 @@ This test section has some text. doc.rev='01' doc.save_with_history([nr_event]) self.write_bofreq_file(doc) - editors = doc.latest_event(BofreqEditorDocEvent).editors.all() + editors = bofreq_editors(doc) + responsible = bofreq_responsible(doc) url = urlreverse('ietf.doc.views_doc.document_main', kwargs=dict(name=doc)) r = self.client.get(url) self.assertContains(r,'Version: 01',status_code=200) @@ -77,12 +88,15 @@ This test section has some text. 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(4, len(q('td.edit>a.btn'))) + self.assertEqual(5, len(q('td.edit>a.btn'))) self.client.logout() self.assertNotEqual([],q('#change-request')) editor = editors.first().user.username @@ -98,9 +112,10 @@ This test section has some text. self.assertContains(r,'Version: 00',status_code=200) self.assertContains(r,'is for an older version') + def test_edit_title(self): doc = BofreqFactory() - editor = doc.latest_event(BofreqEditorDocEvent).editors.first() + 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')) @@ -133,7 +148,7 @@ This test section has some text. def test_edit_state(self): doc = BofreqFactory() - editor = doc.latest_event(BofreqEditorDocEvent).editors.first() + 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'))) @@ -162,7 +177,7 @@ This test section has some text. def test_change_editors(self): doc = BofreqFactory() - previous_editors = list(doc.latest_event(BofreqEditorDocEvent).editors.all()) + previous_editors = list(bofreq_editors(doc)) acting_editor = previous_editors[0] new_editors = set(previous_editors) new_editors.discard(acting_editor) @@ -171,13 +186,13 @@ This test section has some text. 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 = doc.latest_event(BofreqEditorDocEvent).editors.all() + 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 = doc.latest_event(BofreqEditorDocEvent).editors.all() + 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'): @@ -185,8 +200,8 @@ This test section has some text. 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: - unescaped = unicontent(r).encode('utf-8').decode('unicode-escape') self.assertIn(editor.name,unescaped) new_editors = set(previous_editors) new_editors.discard(acting_editor) @@ -194,13 +209,74 @@ This test section has some text. 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 = doc.latest_event(BofreqEditorDocEvent).editors.all() + 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)) @@ -219,7 +295,7 @@ This test section has some text. self.assertEqual(rev, doc.rev) self.client.logout() - editor = doc.latest_event(BofreqEditorDocEvent).editors.first() + 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) @@ -267,7 +343,7 @@ This test section has some text. self.assertEqual(bofreq.title, postdict['title']) self.assertEqual(bofreq.rev, '00') self.assertEqual(bofreq.get_state_slug(), 'proposed') - self.assertEqual(list(bofreq.latest_event(BofreqEditorDocEvent).editors.all()), [nobody]) + 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) diff --git a/ietf/doc/urls_bofreq.py b/ietf/doc/urls_bofreq.py index bbc70ce78..856f99e63 100644 --- a/ietf/doc/urls_bofreq.py +++ b/ietf/doc/urls_bofreq.py @@ -8,5 +8,6 @@ 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'^editors/$', views_bofreq.change_editors), + url(r'^responsible/$', views_bofreq.change_responsible), ] \ No newline at end of file diff --git a/ietf/doc/utils_bofreq.py b/ietf/doc/utils_bofreq.py new file mode 100644 index 000000000..aec8f60ad --- /dev/null +++ b/ietf/doc/utils_bofreq.py @@ -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() \ No newline at end of file diff --git a/ietf/doc/views_bofreq.py b/ietf/doc/views_bofreq.py index abe011ad7..94e821908 100644 --- a/ietf/doc/views_bofreq.py +++ b/ietf/doc/views_bofreq.py @@ -12,10 +12,12 @@ 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, ) -from ietf.doc.models import Document, DocAlias, DocEvent, NewRevisionDocEvent, BofreqEditorDocEvent, State +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 @@ -23,13 +25,13 @@ 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) return render(request, 'doc/bofreq/bof_requests.html',dict(reqs=reqs)) + def edit_relations(request, name): raise NotImplementedError @@ -74,7 +76,7 @@ class BofreqUploadForm(forms.Form): @login_required def submit(request, name): bofreq = get_object_or_404(Document, type="bofreq", name=name) - previous_editors = bofreq.latest_event(BofreqEditorDocEvent).editors.all() + previous_editors = bofreq_editors(bofreq) if not (has_role(request.user,('Secretariat', 'Area Director', 'IAB')) or request.user.person in previous_editors): permission_denied(request,"You do not have permission to upload a new revision of this BOF Request") @@ -187,7 +189,7 @@ class ChangeEditorsForm(forms.Form): @login_required def change_editors(request, name): bofreq = get_object_or_404(Document, type="bofreq", name=name) - previous_editors = bofreq.latest_event(BofreqEditorDocEvent).editors.all() + previous_editors = bofreq_editors(bofreq) if not (has_role(request.user,('Secretariat', 'Area Director', 'IAB')) or request.user.person in previous_editors): permission_denied(request,"You do not have permission to change this document's editors") @@ -214,13 +216,58 @@ def change_editors(request, name): }, ) + +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.latest_event(BofreqEditorDocEvent).editors.all() + editors = bofreq_editors(bofreq) if not (has_role(request.user,('Secretariat', 'Area Director', 'IAB')) or request.user.person in editors): permission_denied(request, "You do not have permission to edit this document's title") diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 3f10480da..56b021686 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -55,15 +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, - BofreqEditorDocEvent ) + 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, @@ -529,9 +529,9 @@ def document_main(request, name, rev=None): )) if doc.type_id == "bofreq": -# content = markdown2.markdown(doc.text_or_error(),extras=['tables']) content = markdown.markdown(doc.text_or_error(),extensions=['extra']) - editors = doc.latest_event(BofreqEditorDocEvent).editors.all() + editors = bofreq_editors(doc) + responsible = bofreq_responsible(doc) can_manage = has_role(request.user,['Secretariat', 'Area Director', 'IAB']) is_editor = request.user.is_authenticated and request.user.person in editors @@ -544,6 +544,7 @@ def document_main(request, name, rev=None): snapshot=snapshot, can_manage=can_manage, editors=editors, + responsible=responsible, is_editor=is_editor, )) diff --git a/ietf/group/tests_info.py b/ietf/group/tests_info.py index 8470981cc..dc0396dcb 100644 --- a/ietf/group/tests_info.py +++ b/ietf/group/tests_info.py @@ -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 = { diff --git a/ietf/mailtrigger/migrations/0023_bofreq_triggers.py b/ietf/mailtrigger/migrations/0023_bofreq_triggers.py index cfba57644..d9a28d4fd 100644 --- a/ietf/mailtrigger/migrations/0023_bofreq_triggers.py +++ b/ietf/mailtrigger/migrations/0023_bofreq_triggers.py @@ -11,20 +11,27 @@ def forward(apps, schema_editor): 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=['doc_ad', 'bofreq_editors', 'doc_notify'])) + 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=['doc_ad', 'bofreq_editors', 'bofreq_previous_editors', 'doc_notify'])) + 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=['doc_ad', 'bofreq_editors', 'doc_notify'])) + mt.to.set(Recipient.objects.filter(slug__in=['bofreq_responsible', 'bofreq_editors', 'doc_notify'])) def reverse(apps, schema_editor): MailTrigger = apps.get_model('mailtrigger', 'MailTrigger') Recipient = apps.get_model('mailtrigger', 'Recipient') - MailTrigger.objects.filter(slug__in=('bofreq_title_changed','bofreq_editors_changed','bofreq_new_revision')).delete() - Recipient.objects.filter(slug__in=('bofreq_editors','bofreq_previous_editors')).delete() + 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): diff --git a/ietf/mailtrigger/models.py b/ietf/mailtrigger/models.py index 70e70fdc5..3cda3d24e 100644 --- a/ietf/mailtrigger/models.py +++ b/ietf/mailtrigger/models.py @@ -6,7 +6,8 @@ from django.db import models from django.template import Template, Context from email.utils import parseaddr -from ietf.doc.models import BofreqEditorDocEvent + +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 @@ -398,8 +399,29 @@ class Recipient(models.Model): addrs = [] if 'doc' in kwargs: bofreq = kwargs['doc'] - editor_event = bofreq.latest_event(BofreqEditorDocEvent) - if editor_event: - addrs.extend([editor.email_address() for editor in editor_event.editors.all()]) + 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 = 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 diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index 28b3d9038..3c4ed4eb6 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -3535,7 +3535,7 @@ "to": [ "bofreq_editors", "bofreq_previous_editors", - "doc_ad", + "bofreq_responsible", "doc_notify" ] }, @@ -3548,20 +3548,34 @@ "desc": "Recipients when a new revision of a BOF request is uploaded.", "to": [ "bofreq_editors", - "doc_ad", + "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", - "doc_ad", + "bofreq_responsible", "doc_notify" ] }, @@ -5237,6 +5251,22 @@ "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", diff --git a/ietf/templates/doc/bofreq/bof_requests.html b/ietf/templates/doc/bofreq/bof_requests.html index 77e366659..4127de8f0 100644 --- a/ietf/templates/doc/bofreq/bof_requests.html +++ b/ietf/templates/doc/bofreq/bof_requests.html @@ -7,26 +7,35 @@ {% block content %} {% origin %} - {% 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></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> - </tr> - {% endfor %} - </tbody> - </table> + <h1>BOF Requests</h1> + + {% 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></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> + </tr> + {% endfor %} + </tbody> + </table> + </div> </div> - </div> - {% endfor %} + {% endfor %} + {% endif %} + {% if request.user.is_authenticated %} + <a id="start_button" class="btn btn-primary" href="{% url 'ietf.doc.views_bofreq.new_bof_request' %}">Start New Bof Request</a> + {% endif %} {% endblock %} \ No newline at end of file diff --git a/ietf/templates/doc/bofreq/change_responsible.html b/ietf/templates/doc/bofreq/change_responsible.html new file mode 100644 index 000000000..23b8609bd --- /dev/null +++ b/ietf/templates/doc/bofreq/change_responsible.html @@ -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 %} \ No newline at end of file diff --git a/ietf/templates/doc/document_bofreq.html b/ietf/templates/doc/document_bofreq.html index abd37c23b..4529bcf3d 100644 --- a/ietf/templates/doc/document_bofreq.html +++ b/ietf/templates/doc/document_bofreq.html @@ -87,7 +87,7 @@ <th>Editor{{editors|pluralize}}</th> <td class="edit"> {% if not snapshot %} - {% if is_editor or can_manage %} + {% if is_editor or can_manage %} {% doc_edit_button 'ietf.doc.views_bofreq.change_editors' name=doc.name %} {% endif %} {% endif %} @@ -99,6 +99,22 @@ </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> + </tbody> <tbody class="meta"> diff --git a/ietf/templates/doc/mail/bofreq_responsible_changed.txt b/ietf/templates/doc/mail/bofreq_responsible_changed.txt new file mode 100644 index 000000000..134d9f8f2 --- /dev/null +++ b/ietf/templates/doc/mail/bofreq_responsible_changed.txt @@ -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 %} \ No newline at end of file