From 6b383255adb4345ff1717aaef0371f05715445bb Mon Sep 17 00:00:00 2001 From: Robert Sparks Date: Tue, 6 Jul 2021 18:05:54 +0000 Subject: [PATCH] Staging for merge forward - Legacy-Id: 19199 --- ietf/doc/admin.py | 9 +- ietf/doc/factories.py | 41 ++- ietf/doc/mails.py | 29 +- ietf/doc/migrations/0042_bofreq_states.py | 36 +++ .../migrations/0043_bofreqeditordocevent.py | 23 ++ ietf/doc/models.py | 10 + ietf/doc/tests_bofreq.py | 285 +++++++++++++++++ ietf/doc/urls.py | 5 +- ietf/doc/urls_bofreq.py | 12 + ietf/doc/views_bofreq.py | 296 ++++++++++++++++++ ietf/doc/views_doc.py | 23 +- ietf/doc/views_help.py | 1 + .../migrations/0023_bofreq_triggers.py | 38 +++ ietf/mailtrigger/models.py | 11 + ietf/name/fixtures/names.json | 182 ++++++++++- ietf/name/migrations/0024_add_bofrequest.py | 23 ++ ietf/settings.py | 2 + ietf/static/ietf/js/upload_bofreq.js | 26 ++ ietf/templates/base/menu.html | 1 + ietf/templates/doc/bofreq/bof_requests.html | 32 ++ ietf/templates/doc/bofreq/bofreq_template.md | 28 ++ ietf/templates/doc/bofreq/change_editors.html | 30 ++ ietf/templates/doc/bofreq/new_bofreq.html | 28 ++ ietf/templates/doc/bofreq/upload_content.html | 28 ++ ietf/templates/doc/document_bofreq.html | 137 ++++++++ .../doc/mail/bofreq_editors_changed.txt | 6 + .../doc/mail/bofreq_new_revision.txt | 3 + .../doc/mail/bofreq_title_changed.txt | 3 + ietf/utils/test_utils.py | 1 - requirements.txt | 1 + 30 files changed, 1329 insertions(+), 21 deletions(-) create mode 100644 ietf/doc/migrations/0042_bofreq_states.py create mode 100644 ietf/doc/migrations/0043_bofreqeditordocevent.py create mode 100644 ietf/doc/tests_bofreq.py create mode 100644 ietf/doc/urls_bofreq.py create mode 100644 ietf/doc/views_bofreq.py create mode 100644 ietf/mailtrigger/migrations/0023_bofreq_triggers.py create mode 100644 ietf/name/migrations/0024_add_bofrequest.py create mode 100644 ietf/static/ietf/js/upload_bofreq.js create mode 100644 ietf/templates/doc/bofreq/bof_requests.html create mode 100644 ietf/templates/doc/bofreq/bofreq_template.md create mode 100644 ietf/templates/doc/bofreq/change_editors.html create mode 100644 ietf/templates/doc/bofreq/new_bofreq.html create mode 100644 ietf/templates/doc/bofreq/upload_content.html create mode 100644 ietf/templates/doc/document_bofreq.html create mode 100644 ietf/templates/doc/mail/bofreq_editors_changed.txt create mode 100644 ietf/templates/doc/mail/bofreq_new_revision.txt create mode 100644 ietf/templates/doc/mail/bofreq_title_changed.txt diff --git a/ietf/doc/admin.py b/ietf/doc/admin.py index b10b061b2..6f4f30143 100644 --- a/ietf/doc/admin.py +++ b/ietf/doc/admin.py @@ -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 ) from ietf.utils.validators import validate_external_resource_value @@ -192,6 +193,10 @@ admin.site.register(BallotPositionDocEvent, BallotPositionDocEventAdmin) 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 DocumentUrlAdmin(admin.ModelAdmin): list_display = ['id', 'doc', 'tag', 'url', 'desc', ] diff --git a/ietf/doc/factories.py b/ietf/doc/factories.py index 33954548e..e37e8119d 100644 --- a/ietf/doc/factories.py +++ b/ietf/doc/factories.py @@ -13,8 +13,11 @@ 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 ) from ietf.group.models import Group +from ietf.person.factories import PersonFactory +from ietf.utils.text import xslugify + def draft_name_generator(type_id,group,n): return '%s-%s-%s-%s%d'%( @@ -379,3 +382,39 @@ 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 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') + + @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')) + diff --git a/ietf/doc/mails.py b/ietf/doc/mails.py index d86b88597..fc9a2bd26 100644 --- a/ietf/doc/mails.py +++ b/ietf/doc/mails.py @@ -18,7 +18,7 @@ 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 +from ietf.doc.models import WriteupDocEvent, LastCallDocEvent, DocAlias, ConsensusDocEvent, BofreqEditorDocEvent from ietf.doc.utils import needed_ballot_positions from ietf.group.models import Role from ietf.doc.models import Document @@ -689,3 +689,30 @@ 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 email_bofreq_editors_changed(request, bofreq, previous_editors): + editors = bofreq.latest_event(BofreqEditorDocEvent).editors.all() + 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), + 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) diff --git a/ietf/doc/migrations/0042_bofreq_states.py b/ietf/doc/migrations/0042_bofreq_states.py new file mode 100644 index 000000000..0b13e95cc --- /dev/null +++ b/ietf/doc/migrations/0042_bofreq_states.py @@ -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', '0024_add_bofrequest'), + ] + + operations = [ + migrations.RunPython(forward, reverse) + ] diff --git a/ietf/doc/migrations/0043_bofreqeditordocevent.py b/ietf/doc/migrations/0043_bofreqeditordocevent.py new file mode 100644 index 000000000..e6c3b433a --- /dev/null +++ b/ietf/doc/migrations/0043_bofreqeditordocevent.py @@ -0,0 +1,23 @@ +# 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 ba49b0741..cab807c4d 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -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,6 @@ 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): + editors = models.ManyToManyField('person.Person',blank=True) \ No newline at end of file diff --git a/ietf/doc/tests_bofreq.py b/ietf/doc/tests_bofreq.py new file mode 100644 index 000000000..c6910e0d2 --- /dev/null +++ b/ietf/doc/tests_bofreq.py @@ -0,0 +1,285 @@ +# Copyright The IETF Trust 2021 All Rights Reserved + +import debug # pyflakes:ignore +import io +import shutil +import os + +from pyquery import PyQuery +from tempfile import NamedTemporaryFile + +from django.conf import settings +from django.urls import reverse as urlreverse + +from ietf.doc.factories import BofreqFactory, NewRevisionDocEventFactory +from ietf.doc.models import State, BofreqEditorDocEvent, Document, DocAlias, NewRevisionDocEvent +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): + 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) + self.assertEqual(len(q('#bofreqs-proposed tbody tr')), states.count()) + for i in range(states.count()): + reqs[i].set_state(states[i]) + 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} tbody tr')), 1) + + + 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 = doc.latest_event(BofreqEditorDocEvent).editors.all() + 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) + 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.name,editor_row) + for user in ('secretary','ad'): + 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.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(2, 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 = doc.latest_event(BofreqEditorDocEvent).editors.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', 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 = doc.latest_event(BofreqEditorDocEvent).editors.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'): + 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(doc.latest_event(BofreqEditorDocEvent).editors.all()) + 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 = doc.latest_event(BofreqEditorDocEvent).editors.all() + 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() + self.assertEqual(set(previous_editors),set(editors)) + self.client.logout() + for username in (previous_editors[0].user.username, 'secretary', 'ad'): + empty_outbox() + self.client.login(username=username,password=username+'+password') + r = self.client.get(url) + self.assertEqual(r.status_code,200) + 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) + 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 = doc.latest_event(BofreqEditorDocEvent).editors.all() + 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_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 = doc.latest_event(BofreqEditorDocEvent).editors.first() + for username in ('secretary', 'ad', 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) + 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.latest_event(BofreqEditorDocEvent).editors.all()), [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')) diff --git a/ietf/doc/urls.py b/ietf/doc/urls.py index fc4e0d959..6149e5684 100644 --- a/ietf/doc/urls.py +++ b/ietf/doc/urls.py @@ -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[\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), @@ -144,6 +146,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')), diff --git a/ietf/doc/urls_bofreq.py b/ietf/doc/urls_bofreq.py new file mode 100644 index 000000000..bbc70ce78 --- /dev/null +++ b/ietf/doc/urls_bofreq.py @@ -0,0 +1,12 @@ + +from ietf.doc import views_bofreq, views_doc +from ietf.utils.urls import url + +urlpatterns = [ + url(r'^notices/$', views_doc.edit_notify, name='ietf.doc.views_doc.edit_notify;bofreq'), + url(r'^relations/$', views_bofreq.edit_relations), + 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), +] \ No newline at end of file diff --git a/ietf/doc/views_bofreq.py b/ietf/doc/views_bofreq.py new file mode 100644 index 000000000..ec764a43f --- /dev/null +++ b/ietf/doc/views_bofreq.py @@ -0,0 +1,296 @@ +# 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, ) +from ietf.doc.models import Document, DocAlias, DocEvent, NewRevisionDocEvent, BofreqEditorDocEvent, State +from ietf.doc.utils import add_state_change_event +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) + return render(request, 'doc/bofreq/bof_requests.html',dict(reqs=reqs)) + +def edit_relations(request, name): + raise NotImplementedError + + +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", "") + 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.latest_event(BofreqEditorDocEvent).editors.all() + if not (has_role(request.user,('Secretariat','Area Director')) or 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.latest_event(BofreqEditorDocEvent).editors.all() + if not (has_role(request.user,('Secretariat','Area Director')) or 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 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() + if not (has_role(request.user,('Secretariat','Area Director')) or 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") +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")), + )) \ No newline at end of file diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index c1b4a59eb..c41f4a5c0 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -40,6 +40,7 @@ import io import json import os import re +import markdown from urllib.parse import quote @@ -54,7 +55,8 @@ 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, + BofreqEditorDocEvent ) 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, @@ -526,6 +528,25 @@ def document_main(request, name, rev=None): can_manage=can_manage, )) + 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() + can_manage = has_role(request.user,['Secretariat','Area Director']) + is_editor = 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, + is_editor=is_editor, + )) + if doc.type_id == "conflrev": filename = "%s-%s.txt" % (doc.canonical_name(), doc.rev) pathname = os.path.join(settings.CONFLICT_REVIEW_PATH,filename) diff --git a/ietf/doc/views_help.py b/ietf/doc/views_help.py index 38d96be99..56b354cc4 100644 --- a/ietf/doc/views_help.py +++ b/ietf/doc/views_help.py @@ -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) diff --git a/ietf/mailtrigger/migrations/0023_bofreq_triggers.py b/ietf/mailtrigger/migrations/0023_bofreq_triggers.py new file mode 100644 index 000000000..cfba57644 --- /dev/null +++ b/ietf/mailtrigger/migrations/0023_bofreq_triggers.py @@ -0,0 +1,38 @@ +# 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 %}') + + 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 = 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 = 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'])) + +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() + + +class Migration(migrations.Migration): + + dependencies = [ + ('mailtrigger', '0022_add_doc_external_resource_change_requested'), + ] + + operations = [ + migrations.RunPython(forward,reverse) + ] diff --git a/ietf/mailtrigger/models.py b/ietf/mailtrigger/models.py index 3f8b2b0ea..70e70fdc5 100644 --- a/ietf/mailtrigger/models.py +++ b/ietf/mailtrigger/models.py @@ -6,6 +6,7 @@ from django.db import models from django.template import Template, Context from email.utils import parseaddr +from ietf.doc.models import BofreqEditorDocEvent 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 +393,13 @@ 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'] + editor_event = bofreq.latest_event(BofreqEditorDocEvent) + if editor_event: + addrs.extend([editor.email_address() for editor in editor_event.editors.all()]) + return addrs + diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index c2d4aa03f..0d4b75290 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -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" @@ -2990,7 +3067,7 @@ "about_page": "ietf.group.views.group_about", "acts_like_wg": false, "admin_roles": "[\n \"chair\",\n \"secr\"\n]", - "agenda_type": null, + "agenda_type": "ietf", "create_wiki": true, "custom_group_roles": true, "customize_workflow": false, @@ -3002,7 +3079,7 @@ "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": true, @@ -3349,6 +3426,46 @@ "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", + "doc_ad", + "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", + "doc_ad", + "doc_notify" + ] + }, + "model": "mailtrigger.mailtrigger", + "pk": "bofreq_new_revision" + }, + { + "fields": { + "cc": [], + "desc": "Recipients when the title of a BOF proposal is changed.", + "to": [ + "bofreq_editors", + "doc_ad", + "doc_notify" + ] + }, + "model": "mailtrigger.mailtrigger", + "pk": "bofreq_title_changed" + }, { "fields": { "cc": [ @@ -4063,6 +4180,7 @@ "fields": { "cc": [ "liaison_cc", + "liaison_coordinators", "liaison_response_contacts", "liaison_technical_contacts" ], @@ -5001,6 +5119,22 @@ "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": "The person providing a comment to nomcom", @@ -5401,6 +5535,14 @@ "model": "mailtrigger.recipient", "pk": "liaison_cc" }, + { + "fields": { + "desc": "The IAB liaison coordination team members", + "template": "" + }, + "model": "mailtrigger.recipient", + "pk": "liaison_coordinators" + }, { "fields": { "desc": "The assigned liaison manager for an external group ", @@ -9594,6 +9736,17 @@ "model": "name.doctypename", "pk": "bluesheets" }, + { + "fields": { + "desc": "", + "name": "Bof Request", + "order": 0, + "prefix": "bofreq", + "used": true + }, + "model": "name.doctypename", + "pk": "bofreq" + }, { "fields": { "desc": "", @@ -9901,6 +10054,7 @@ "auth", "aut-appr", "grp-appr", + "ad-appr", "manual", "cancel" ], @@ -11948,7 +12102,7 @@ "fields": { "desc": "", "name": "Liaison CC Contact", - "order": 0, + "order": 9, "used": true }, "model": "name.rolename", @@ -11958,7 +12112,7 @@ "fields": { "desc": "", "name": "Liaison Contact", - "order": 0, + "order": 8, "used": true }, "model": "name.rolename", @@ -12386,7 +12540,7 @@ }, { "fields": { - "desc": "IAB stream", + "desc": "Internet Architecture Board (IAB)", "name": "IAB", "order": 4, "used": true @@ -12396,7 +12550,7 @@ }, { "fields": { - "desc": "IETF stream", + "desc": "Internet Engineering Task Force (IETF)", "name": "IETF", "order": 1, "used": true @@ -12406,7 +12560,7 @@ }, { "fields": { - "desc": "IRTF Stream", + "desc": "Internet Research Task Force (IRTF)", "name": "IRTF", "order": 3, "used": true @@ -12416,7 +12570,7 @@ }, { "fields": { - "desc": "Independent Submission Editor stream", + "desc": "Independent Submission", "name": "ISE", "order": 2, "used": true @@ -15141,9 +15295,9 @@ "fields": { "command": "xym", "switch": "--version", - "time": "2020-11-14T00:10:15.888", + "time": "2021-05-20T00:12:38.672", "used": true, - "version": "xym 0.4.8" + "version": "xym 0.5" }, "model": "utils.versioninfo", "pk": 1 @@ -15152,7 +15306,7 @@ "fields": { "command": "pyang", "switch": "--version", - "time": "2020-11-14T00:10:17.069", + "time": "2021-05-20T00:12:40.445", "used": true, "version": "pyang 2.4.0" }, @@ -15163,7 +15317,7 @@ "fields": { "command": "yanglint", "switch": "--version", - "time": "2020-11-14T00:10:17.405", + "time": "2021-05-20T00:12:40.647", "used": true, "version": "yanglint SO 1.6.7" }, @@ -15174,9 +15328,9 @@ "fields": { "command": "xml2rfc", "switch": "--version", - "time": "2020-11-14T00:10:19.405", + "time": "2021-05-20T00:12:43.645", "used": true, - "version": "xml2rfc 3.4.0" + "version": "xml2rfc 3.7.0" }, "model": "utils.versioninfo", "pk": 4 diff --git a/ietf/name/migrations/0024_add_bofrequest.py b/ietf/name/migrations/0024_add_bofrequest.py new file mode 100644 index 000000000..79e95e7cd --- /dev/null +++ b/ietf/name/migrations/0024_add_bofrequest.py @@ -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', '0023_change_stream_descriptions'), + ] + + operations = [ + migrations.RunPython(forward, reverse) + ] diff --git a/ietf/settings.py b/ietf/settings.py index 51eaecded..a8619618a 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -644,6 +644,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[-a-z0-9]+)", + "bofreq": r"(?Pbofreq-[-a-z0-9]+)", "charter": r"(?Pcharter-[-a-z0-9]+)", "date": r"(?P\d{4}-\d{2}-\d{2})", "name": r"(?P[A-Za-z0-9._+-]+?)", @@ -662,6 +663,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/' diff --git a/ietf/static/ietf/js/upload_bofreq.js b/ietf/static/ietf/js/upload_bofreq.js new file mode 100644 index 000000000..bdfc3617f --- /dev/null +++ b/ietf/static/ietf/js/upload_bofreq.js @@ -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"); +}); \ No newline at end of file diff --git a/ietf/templates/base/menu.html b/ietf/templates/base/menu.html index 12fea0df6..19db79876 100644 --- a/ietf/templates/base/menu.html +++ b/ietf/templates/base/menu.html @@ -27,6 +27,7 @@
  • New work
  • Chartering groups
  • BOFs
  • +
  • BOF Requests
  • {% if flavor == "top" %}{% endif %}
  • Other groups
  • diff --git a/ietf/templates/doc/bofreq/bof_requests.html b/ietf/templates/doc/bofreq/bof_requests.html new file mode 100644 index 000000000..77e366659 --- /dev/null +++ b/ietf/templates/doc/bofreq/bof_requests.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2021 All Rights Reserved #} +{% load origin %} + +{% block title %}BOF Requests{% endblock %} + +{% block content %} + {% origin %} + + {% regroup reqs by get_state_slug as grouped_reqs %} + {% for req_group in grouped_reqs %} +
    +
    {{req_group.grouper|capfirst}} BOF Requests
    +
    + + + + + + {% for req in req_group.list %} + + + + + + {% endfor %} + +
    NameDateTitle
    {{req.name}}-{{req.rev}}{{req.latest_revision_event.time|date:"Y-m-d"}}{{req.title}}
    +
    +
    + {% endfor %} +{% endblock %} \ No newline at end of file diff --git a/ietf/templates/doc/bofreq/bofreq_template.md b/ietf/templates/doc/bofreq/bofreq_template.md new file mode 100644 index 000000000..8f81e60eb --- /dev/null +++ b/ietf/templates/doc/bofreq/bofreq_template.md @@ -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 , name (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 \ No newline at end of file diff --git a/ietf/templates/doc/bofreq/change_editors.html b/ietf/templates/doc/bofreq/change_editors.html new file mode 100644 index 000000000..4570a0692 --- /dev/null +++ b/ietf/templates/doc/bofreq/change_editors.html @@ -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 %} +

    Change editors
    {{ titletext }}

    + +
    + {% csrf_token %} + {% bootstrap_form form %} + + {% buttons %} + + Back + {% endbuttons %} +
    +{% endblock %} + +{% block js %} + {{ form.media.js }} +{% endblock %} \ No newline at end of file diff --git a/ietf/templates/doc/bofreq/new_bofreq.html b/ietf/templates/doc/bofreq/new_bofreq.html new file mode 100644 index 000000000..b33839542 --- /dev/null +++ b/ietf/templates/doc/bofreq/new_bofreq.html @@ -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 %} +

    Start a new BoF Request

    + +

    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.

    +

    For example, a request with a title of "A new important bit" will be saved as "bofreq-a-new-important-bit-00.md".

    +
    + {% csrf_token %} + + {% bootstrap_form form layout="horizontal" %} + + {% buttons %} + Cancel + + {% endbuttons %} +
    +{% endblock %} + +{% block js %} + +{% endblock %} + diff --git a/ietf/templates/doc/bofreq/upload_content.html b/ietf/templates/doc/bofreq/upload_content.html new file mode 100644 index 000000000..5591911f0 --- /dev/null +++ b/ietf/templates/doc/bofreq/upload_content.html @@ -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 %} +

    Upload New Revision
    + {{ doc.name }} +

    + +
    + {% csrf_token %} + + {% bootstrap_form form layout="horizontal" %} + + {% buttons %} + Cancel + + {% endbuttons %} +
    +{% endblock %} + +{% block js %} + +{% endblock %} + diff --git a/ietf/templates/doc/document_bofreq.html b/ietf/templates/doc/document_bofreq.html new file mode 100644 index 000000000..abd37c23b --- /dev/null +++ b/ietf/templates/doc/document_bofreq.html @@ -0,0 +1,137 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2021, All Rights Reserved #} +{% load origin %} +{% load static %} +{% load ietf_filters %} +{% load person_filters %} + +{% block pagehead %} + + + +{% endblock %} + +{% block title %}{{ doc.title }}{% endblock %} + +{% block content %} + {% origin %} + {{ top|safe }} + + {% include "doc/revisions_list.html" %} +
    + + + + + {% if doc.rev != latest_rev %} + + {% else %} + + {% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    The information below is for an older version of this BOF request
    DocumentType + {{doc.get_state.slug|capfirst}} BOF request + {% if snapshot %} + Snapshot + {% endif %} +
    Title + {% if not snapshot %} + {% if is_editor or can_manage %} + {% doc_edit_button 'ietf.doc.views_bofreq.edit_title' name=doc.name %} + {% endif %} + {% endif %} + {{ doc.title }}
    Last updated{{ doc.time|date:"Y-m-d" }}
    State + {% if not snapshot and can_manage %} + {% doc_edit_button 'ietf.doc.views_bofreq.change_state' name=doc.name %} + {% endif %} + + {% if doc.get_state %} + {{ doc.get_state.name }} + {% else %} + No document state + {% endif %} +
    Editor{{editors|pluralize}} + {% if not snapshot %} + {% if is_editor or can_manage %} + {% doc_edit_button 'ietf.doc.views_bofreq.change_editors' name=doc.name %} + {% endif %} + {% endif %} + + {% for editor in editors %} + {% person_link editor %}{% if not forloop.last %}, {% endif %} + {% endfor %} +
    Send notices to + {% if not snapshot %} + {% if can_manage %} + {% doc_edit_button 'ietf.doc.views_doc.edit_notify;bofreq' name=doc.name %} + {% endif %} + {% endif %} + + {{ doc.notify|default:"(None)" }} +
    + + {% if not snapshot %} + {% if is_editor or can_manage %} +

    Change BOF request text

    + {% endif %} + {% endif %} + +
    +
    {{doc.name}}-{{doc.rev}}
    +
    + {{ content|sanitize|safe }} +
    +
    + +{% endblock %} diff --git a/ietf/templates/doc/mail/bofreq_editors_changed.txt b/ietf/templates/doc/mail/bofreq_editors_changed.txt new file mode 100644 index 000000000..647fe1317 --- /dev/null +++ b/ietf/templates/doc/mail/bofreq_editors_changed.txt @@ -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 %} \ No newline at end of file diff --git a/ietf/templates/doc/mail/bofreq_new_revision.txt b/ietf/templates/doc/mail/bofreq_new_revision.txt new file mode 100644 index 000000000..5db056263 --- /dev/null +++ b/ietf/templates/doc/mail/bofreq_new_revision.txt @@ -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 %} diff --git a/ietf/templates/doc/mail/bofreq_title_changed.txt b/ietf/templates/doc/mail/bofreq_title_changed.txt new file mode 100644 index 000000000..506df6ea4 --- /dev/null +++ b/ietf/templates/doc/mail/bofreq_title_changed.txt @@ -0,0 +1,3 @@ +{% autoescape off %}{{request.user.person.name}} has changed the title for {{bofreq.name}}-{{bofreq.rev}} to +"{{bofreq.title}}" +{% endautoescape %} \ No newline at end of file diff --git a/ietf/utils/test_utils.py b/ietf/utils/test_utils.py index 495ee77ad..b9a3abbdf 100644 --- a/ietf/utils/test_utils.py +++ b/ietf/utils/test_utils.py @@ -93,7 +93,6 @@ def reload_db_objects(*objects): """Rerequest the given arguments from the database so they're refreshed, to be used like foo, bar = reload_db_objects(foo, bar)""" - t = tuple(o.__class__.objects.get(pk=o.pk) for o in objects) if len(objects) == 1: return t[0] diff --git a/requirements.txt b/requirements.txt index 40588ec7a..d930a341c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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.