diff --git a/ietf/doc/mails.py b/ietf/doc/mails.py index ddecbb6b54..f20d398c3c 100644 --- a/ietf/doc/mails.py +++ b/ietf/doc/mails.py @@ -103,6 +103,61 @@ def email_stream_changed(request, doc, old_stream, new_stream, text=""): dict(text=text, url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url()), cc=cc) + +def email_wg_call_for_adoption_issued(request, doc, cfa_duration_weeks=None): + if cfa_duration_weeks is None: + cfa_duration_weeks=2 + (to, cc) = gather_address_lists("doc_wg_call_for_adoption_issued", doc=doc) + frm = request.user.person.formatted_email() + + end_date = date_today(DEADLINE_TZINFO) + datetime.timedelta(days=7 * cfa_duration_weeks) + + subject = f"Call for adoption: {doc.name}-{doc.rev} (Ends {end_date})" + + send_mail( + request, + to, + frm, + subject, + "doc/mail/wg_call_for_adoption_issued.txt", + dict( + doc=doc, + subject=subject, + url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url(), + end_date=end_date, + cfa_duration_weeks=cfa_duration_weeks, + wg_list=doc.group.list_email, + ), + cc=cc, + ) + + +def email_wg_last_call_issued(request, doc, wglc_duration_weeks=None): + if wglc_duration_weeks is None: + wglc_duration_weeks = 2 + (to, cc) = gather_address_lists("doc_wg_last_call_issued", doc=doc) + frm = request.user.person.formatted_email() + + + end_date = date_today(DEADLINE_TZINFO) + datetime.timedelta(days=7 * wglc_duration_weeks) + subject = f"WG Last Call: {doc.name}-{doc.rev} (Ends {end_date})" + + send_mail( + request, + to, + frm, + subject, + "doc/mail/wg_last_call_issued.txt", + dict( + doc=doc, + subject=subject, + url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url(), + end_date=end_date, + wglc_duration_weeks=wglc_duration_weeks, + wg_list=doc.group.list_email, + ), + cc=cc, + ) def email_pulled_from_rfc_queue(request, doc, comment, prev_state, next_state): extra=extra_automation_headers(doc) diff --git a/ietf/doc/tests_draft.py b/ietf/doc/tests_draft.py index 576feb0582..ab7eaba768 100644 --- a/ietf/doc/tests_draft.py +++ b/ietf/doc/tests_draft.py @@ -1707,11 +1707,12 @@ def test_adopt_document(self): self.assertEqual(draft.group, chair_role.group) self.assertEqual(draft.stream_id, stream_state_type_slug[type_id][13:]) # trim off "draft-stream-" self.assertEqual(draft.docevent_set.count() - events_before, 5) - self.assertEqual(len(outbox), 1) - self.assertTrue("Call For Adoption" in outbox[-1]["Subject"]) - self.assertTrue(f"{chair_role.group.acronym}-chairs@" in outbox[-1]['To']) - self.assertTrue(f"{draft.name}@" in outbox[-1]['To']) - self.assertTrue(f"{chair_role.group.acronym}@" in outbox[-1]['To']) + self.assertEqual(len(outbox), 2) + self.assertTrue("Call For Adoption" in outbox[0]["Subject"]) + self.assertTrue(f"{chair_role.group.acronym}-chairs@" in outbox[0]['To']) + self.assertTrue(f"{draft.name}@" in outbox[0]['To']) + self.assertTrue(f"{chair_role.group.acronym}@" in outbox[0]['To']) + # contents of outbox[1] are tested elsewhere # adopt empty_outbox() @@ -2001,6 +2002,200 @@ def test_set_state(self): self.assertTrue("mars-chairs@ietf.org" in outbox[0].as_string()) self.assertTrue("marsdelegate@ietf.org" in outbox[0].as_string()) + def test_wg_call_for_adoption_issued(self): + role = RoleFactory( + name_id="chair", + group__acronym="mars", + group__list_email="mars-wg@ietf.org", + person__user__username="marschairman", + person__name="WG Cháir Man", + ) + # First test the usual workflow through the manage adoption view + draft = IndividualDraftFactory() + url = urlreverse( + "ietf.doc.views_draft.adopt_draft", kwargs=dict(name=draft.name) + ) + login_testing_unauthorized(self, "marschairman", url) + empty_outbox() + call_issued = State.objects.get(type="draft-stream-ietf", slug="c-adopt") + r = self.client.post( + url, + dict( + comment="some comment", + group=role.group.pk, + newstate=call_issued.pk, + weeks="10", + ), + ) + self.assertEqual(r.status_code, 302) + self.assertEqual(len(outbox), 2) + self.assertIn("mars-wg@ietf.org", outbox[1]["To"]) + self.assertIn("Call for adoption", outbox[1]["Subject"]) + body = get_payload_text(outbox[1]) + self.assertIn("disclosure obligations", body) + self.assertIn("starts a 10-week", body) + # Test not entering a duration on the form + draft = IndividualDraftFactory() + url = urlreverse( + "ietf.doc.views_draft.adopt_draft", kwargs=dict(name=draft.name) + ) + empty_outbox() + call_issued = State.objects.get(type="draft-stream-ietf", slug="c-adopt") + r = self.client.post( + url, + dict( + comment="some comment", + group=role.group.pk, + newstate=call_issued.pk, + ), + ) + self.assertEqual(r.status_code, 302) + self.assertEqual(len(outbox), 2) + self.assertIn("mars-wg@ietf.org", outbox[1]["To"]) + self.assertIn("Call for adoption", outbox[1]["Subject"]) + body = get_payload_text(outbox[1]) + self.assertIn("disclosure obligations", body) + self.assertIn("starts a 2-week", body) + + # Test the less usual workflow of issuing a call for adoption + # of a document that's already in the ietf stream + draft = WgDraftFactory(group=role.group) + url = urlreverse( + "ietf.doc.views_draft.change_stream_state", + kwargs=dict(name=draft.name, state_type="draft-stream-ietf"), + ) + old_state = draft.get_state("draft-stream-%s" % draft.stream_id) + new_state = State.objects.get( + used=True, type="draft-stream-%s" % draft.stream_id, slug="c-adopt" + ) + self.assertNotEqual(old_state, new_state) + empty_outbox() + r = self.client.post( + url, + dict( + new_state=new_state.pk, + comment="some comment", + weeks="10", + tags=[ + t.pk + for t in draft.tags.filter( + slug__in=get_tags_for_stream_id(draft.stream_id) + ) + ], + ), + ) + self.assertEqual(r.status_code, 302) + self.assertEqual(len(outbox), 2) + self.assertIn("mars-wg@ietf.org", outbox[1]["To"]) + self.assertIn("Call for adoption", outbox[1]["Subject"]) + body = get_payload_text(outbox[1]) + self.assertIn("disclosure obligations", body) + self.assertIn("starts a 10-week", body) + draft = WgDraftFactory(group=role.group) + url = urlreverse( + "ietf.doc.views_draft.change_stream_state", + kwargs=dict(name=draft.name, state_type="draft-stream-ietf"), + ) + old_state = draft.get_state("draft-stream-%s" % draft.stream_id) + new_state = State.objects.get( + used=True, type="draft-stream-%s" % draft.stream_id, slug="c-adopt" + ) + self.assertNotEqual(old_state, new_state) + empty_outbox() + r = self.client.post( + url, + dict( + new_state=new_state.pk, + comment="some comment", + tags=[ + t.pk + for t in draft.tags.filter( + slug__in=get_tags_for_stream_id(draft.stream_id) + ) + ], + ), + ) + self.assertEqual(r.status_code, 302) + self.assertEqual(len(outbox), 2) + self.assertIn("mars-wg@ietf.org", outbox[1]["To"]) + self.assertIn("Call for adoption", outbox[1]["Subject"]) + body = get_payload_text(outbox[1]) + self.assertIn("disclosure obligations", body) + self.assertIn("starts a 2-week", body) + + def test_wg_last_call_issued(self): + role = RoleFactory( + name_id="chair", + group__acronym="mars", + group__list_email="mars-wg@ietf.org", + person__user__username="marschairman", + person__name="WG Cháir Man", + ) + draft = WgDraftFactory(group=role.group) + url = urlreverse( + "ietf.doc.views_draft.change_stream_state", + kwargs=dict(name=draft.name, state_type="draft-stream-ietf"), + ) + login_testing_unauthorized(self, "marschairman", url) + old_state = draft.get_state("draft-stream-%s" % draft.stream_id) + new_state = State.objects.get( + used=True, type="draft-stream-%s" % draft.stream_id, slug="wg-lc" + ) + self.assertNotEqual(old_state, new_state) + empty_outbox() + r = self.client.post( + url, + dict( + new_state=new_state.pk, + comment="some comment", + weeks="10", + tags=[ + t.pk + for t in draft.tags.filter( + slug__in=get_tags_for_stream_id(draft.stream_id) + ) + ], + ), + ) + self.assertEqual(r.status_code, 302) + self.assertEqual(len(outbox), 2) + self.assertIn("mars-wg@ietf.org", outbox[1]["To"]) + self.assertIn("WG Last Call", outbox[1]["Subject"]) + body = get_payload_text(outbox[1]) + self.assertIn("disclosure obligations", body) + self.assertIn("starts a 10-week", body) + draft = WgDraftFactory(group=role.group) + url = urlreverse( + "ietf.doc.views_draft.change_stream_state", + kwargs=dict(name=draft.name, state_type="draft-stream-ietf"), + ) + old_state = draft.get_state("draft-stream-%s" % draft.stream_id) + new_state = State.objects.get( + used=True, type="draft-stream-%s" % draft.stream_id, slug="wg-lc" + ) + self.assertNotEqual(old_state, new_state) + empty_outbox() + r = self.client.post( + url, + dict( + new_state=new_state.pk, + comment="some comment", + tags=[ + t.pk + for t in draft.tags.filter( + slug__in=get_tags_for_stream_id(draft.stream_id) + ) + ], + ), + ) + self.assertEqual(r.status_code, 302) + self.assertEqual(len(outbox), 2) + self.assertIn("mars-wg@ietf.org", outbox[1]["To"]) + self.assertIn("WG Last Call", outbox[1]["Subject"]) + body = get_payload_text(outbox[1]) + self.assertIn("disclosure obligations", body) + self.assertIn("starts a 2-week", body) + def test_pubreq_validation(self): role = RoleFactory(name_id='chair',group__acronym='mars',group__list_email='mars-wg@ietf.org',person__user__username='marschairman',person__name='WG Cháir Man') RoleFactory(name_id='delegate',group=role.group,person__user__email='marsdelegate@ietf.org') diff --git a/ietf/doc/views_draft.py b/ietf/doc/views_draft.py index c80537afb3..16d04ee66a 100644 --- a/ietf/doc/views_draft.py +++ b/ietf/doc/views_draft.py @@ -28,6 +28,7 @@ IanaExpertDocEvent, IESG_SUBSTATE_TAGS) from ietf.doc.mails import ( email_pulled_from_rfc_queue, email_resurrect_requested, email_resurrection_completed, email_state_changed, email_stream_changed, + email_wg_call_for_adoption_issued, email_wg_last_call_issued, email_stream_state_changed, email_stream_tags_changed, extra_automation_headers, generate_publication_request, email_adopted, email_intended_status_changed, email_iesg_processing_document, email_ad_approved_doc, @@ -1568,8 +1569,15 @@ def adopt_draft(request, name): update_reminder(doc, "stream-s", e, due_date) + # The following call name is very misleading - the view allows + # setting states that are _not_ the adopted state. email_adopted(request, doc, prev_state, new_state, by, comment) + # Currently only the IETF stream uses the c-adopt state - guard against other + # streams starting to use it asthe IPR rules for those streams will be different. + if doc.stream_id == "ietf" and new_state.slug == "c-adopt": + email_wg_call_for_adoption_issued(request, doc, cfa_duration_weeks=form.cleaned_data["weeks"]) + # comment if comment: e = DocEvent(type="added_comment", doc=doc, rev=doc.rev, by=by) @@ -1754,13 +1762,20 @@ def change_stream_state(request, name, state_type): events.append(e) due_date = None - if form.cleaned_data["weeks"] != None: + if form.cleaned_data["weeks"] is not None: due_date = datetime_today(DEADLINE_TZINFO) + datetime.timedelta(weeks=form.cleaned_data["weeks"]) update_reminder(doc, "stream-s", e, due_date) email_stream_state_changed(request, doc, prev_state, new_state, by, comment) + if doc.stream_id == "ietf": + if new_state.slug == "c-adopt": + email_wg_call_for_adoption_issued(request, doc, cfa_duration_weeks=form.cleaned_data["weeks"]) + + if new_state.slug == "wg-lc": + email_wg_last_call_issued(request, doc, wglc_duration_weeks=form.cleaned_data["weeks"]) + # tags existing_tags = set(doc.tags.all()) new_tags = set(form.cleaned_data["tags"]) diff --git a/ietf/mailtrigger/migrations/0006_call_for_adoption_and_last_call_issued.py b/ietf/mailtrigger/migrations/0006_call_for_adoption_and_last_call_issued.py new file mode 100644 index 0000000000..7adad150eb --- /dev/null +++ b/ietf/mailtrigger/migrations/0006_call_for_adoption_and_last_call_issued.py @@ -0,0 +1,43 @@ +# Copyright The IETF Trust 2023, All Rights Reserved + +from django.db import migrations + + +def forward(apps, schema_editor): + MailTrigger = apps.get_model("mailtrigger", "MailTrigger") + Recipient = apps.get_model("mailtrigger", "Recipient") + recipients = list( + Recipient.objects.filter( + slug__in=( + "doc_group_mail_list", + "doc_authors", + "doc_group_chairs", + "doc_shepherd", + ) + ) + ) + call_for_adoption = MailTrigger.objects.create( + slug="doc_wg_call_for_adoption_issued", + desc="Recipients when a working group call for adoption is issued", + ) + call_for_adoption.to.add(*recipients) + wg_last_call = MailTrigger.objects.create( + slug="doc_wg_last_call_issued", + desc="Recipients when a working group last call is issued", + ) + wg_last_call.to.add(*recipients) + + +def reverse(apps, schema_editor): + MailTrigger = apps.get_model("mailtrigger", "MailTrigger") + MailTrigger.objects.filter( + slug_in=("doc_wg_call_for_adoption_issued", "doc_wg_last_call_issued") + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("mailtrigger", "0005_rfc_recipients"), + ] + + operations = [migrations.RunPython(forward, reverse)] diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index 0724cbb4b5..c94e15a459 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -5116,6 +5116,34 @@ "model": "mailtrigger.mailtrigger", "pk": "doc_telechat_details_changed" }, + { + "fields": { + "cc": [], + "desc": "Recipients when a working group call for adoption is issued", + "to": [ + "doc_authors", + "doc_group_chairs", + "doc_group_mail_list", + "doc_shepherd" + ] + }, + "model": "mailtrigger.mailtrigger", + "pk": "doc_wg_call_for_adoption_issued" + }, + { + "fields": { + "cc": [], + "desc": "Recipients when a working group last call is issued", + "to": [ + "doc_authors", + "doc_group_chairs", + "doc_group_mail_list", + "doc_shepherd" + ] + }, + "model": "mailtrigger.mailtrigger", + "pk": "doc_wg_last_call_issued" + }, { "fields": { "cc": [], diff --git a/ietf/templates/doc/mail/wg_call_for_adoption_issued.txt b/ietf/templates/doc/mail/wg_call_for_adoption_issued.txt new file mode 100644 index 0000000000..c4a2401bc2 --- /dev/null +++ b/ietf/templates/doc/mail/wg_call_for_adoption_issued.txt @@ -0,0 +1,21 @@ +{% load ietf_filters %}{% load mail_filters %}{% autoescape off %}{% filter wordwrap:78 %} +Subject: {{ subject }} + +This message starts a {{ cfa_duration_weeks }}-week Call for Adoption for this document. + +Abstract: +{{ doc.abstract }} + +File can be retrieved from: +{{ url }} + +Please reply to this message keeping {{ wg_list }} in copy by indicating whether you support or not the adoption of this draft as a WG document. Comments to motivate your preference are highly appreciated. + +Authors, and WG participants in general, are reminded of the Intellectual Property Rights (IPR) disclosure obligations described in BCP 79 [2]. Appropriate IPR disclosures required for full conformance with the provisions of BCP 78 [1] and BCP 79 [2] must be filed, if you are aware of any. Sanctions available for application to violators of IETF IPR Policy can be found at [3]. + +Thank you. +[1] https://datatracker.ietf.org/doc/bcp78/ +[2] https://datatracker.ietf.org/doc/bcp79/ +[3] https://datatracker.ietf.org/doc/rfc6701/ +{% endfilter %} +{% endautoescape %} diff --git a/ietf/templates/doc/mail/wg_last_call_issued.txt b/ietf/templates/doc/mail/wg_last_call_issued.txt new file mode 100644 index 0000000000..35b1e149d7 --- /dev/null +++ b/ietf/templates/doc/mail/wg_last_call_issued.txt @@ -0,0 +1,22 @@ +{% load ietf_filters %}{% load mail_filters %}{% autoescape off %}{% filter wordwrap:78 %} +Subject: {{ subject }} + +This message starts a {{ wglc_duration_weeks }}-week WG Last Call for this document. + +Abstract: +{{ doc.abstract }} + +File can be retrieved from: +{{ url }} + +Please review and indicate your support or objection to proceed with the publication of this document by replying to this email keeping {{ wg_list }} in copy. Objections should be motivated and suggestions to resolve them are highly appreciated. + +Authors, and WG participants in general, are reminded again of the Intellectual Property Rights (IPR) disclosure obligations described in BCP 79 [1]. Appropriate IPR disclosures required for full conformance with the provisions of BCP 78 [1] and BCP 79 [2] must be filed, if you are aware of any. Sanctions available for application to violators of IETF IPR Policy can be found at [3]. + +Thank you. + +[1] https://datatracker.ietf.org/doc/bcp78/ +[2] https://datatracker.ietf.org/doc/bcp79/ +[3] https://datatracker.ietf.org/doc/rfc6701/ +{% endfilter %} +{% endautoescape %}