Skip to content

Commit 566cf50

Browse files
nouralmaarjsparks
andauthored
feat: add auto-generated IPR email notifications on WG Call for Adoption or WG Last Call (#9322)
* feat(documents): add auto-generated IPR email notifications on WG Call for Adoption or WG Last Call * fix: edit call durations in mails and call logic under new_state in views * fix: calc end_date as 7 * call_duration * feat(mailtrigger): added new mailtrigger for wg-lc and rfc stream states * test: add mailtrigger test fixtures and new tests * fix: use two action-oriented mailtrigger names The two actions have the same recipients to start with, but that may change over time. Mailtrigger names should describe "what happened to trigger this email?". Changed the utility names to match the actions. * fix: send from whomever issued the call Using a list name as the From will not work - the mail infrastructure blocks such mail when it is submitted. * chore: revert ietf/doc/tests_draft.py * fix: trigger call for adoption email from manage adoption view * fix: changed template names to match functions * fix: match the subject requested in the issue * fix: Initial tests * fix: pass duration to the email message generator * fix: only issue the c-adopt and wg-lc email for ietf-stream docs * chore: remove stray whitespace --------- Co-authored-by: Robert Sparks <[email protected]>
1 parent 8112168 commit 566cf50

File tree

7 files changed

+385
-6
lines changed

7 files changed

+385
-6
lines changed

ietf/doc/mails.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,61 @@ def email_stream_changed(request, doc, old_stream, new_stream, text=""):
103103
dict(text=text,
104104
url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url()),
105105
cc=cc)
106+
107+
def email_wg_call_for_adoption_issued(request, doc, cfa_duration_weeks=None):
108+
if cfa_duration_weeks is None:
109+
cfa_duration_weeks=2
110+
(to, cc) = gather_address_lists("doc_wg_call_for_adoption_issued", doc=doc)
111+
frm = request.user.person.formatted_email()
112+
113+
end_date = date_today(DEADLINE_TZINFO) + datetime.timedelta(days=7 * cfa_duration_weeks)
114+
115+
subject = f"Call for adoption: {doc.name}-{doc.rev} (Ends {end_date})"
116+
117+
send_mail(
118+
request,
119+
to,
120+
frm,
121+
subject,
122+
"doc/mail/wg_call_for_adoption_issued.txt",
123+
dict(
124+
doc=doc,
125+
subject=subject,
126+
url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url(),
127+
end_date=end_date,
128+
cfa_duration_weeks=cfa_duration_weeks,
129+
wg_list=doc.group.list_email,
130+
),
131+
cc=cc,
132+
)
133+
134+
135+
def email_wg_last_call_issued(request, doc, wglc_duration_weeks=None):
136+
if wglc_duration_weeks is None:
137+
wglc_duration_weeks = 2
138+
(to, cc) = gather_address_lists("doc_wg_last_call_issued", doc=doc)
139+
frm = request.user.person.formatted_email()
140+
141+
142+
end_date = date_today(DEADLINE_TZINFO) + datetime.timedelta(days=7 * wglc_duration_weeks)
143+
subject = f"WG Last Call: {doc.name}-{doc.rev} (Ends {end_date})"
144+
145+
send_mail(
146+
request,
147+
to,
148+
frm,
149+
subject,
150+
"doc/mail/wg_last_call_issued.txt",
151+
dict(
152+
doc=doc,
153+
subject=subject,
154+
url=settings.IDTRACKER_BASE_URL + doc.get_absolute_url(),
155+
end_date=end_date,
156+
wglc_duration_weeks=wglc_duration_weeks,
157+
wg_list=doc.group.list_email,
158+
),
159+
cc=cc,
160+
)
106161

107162
def email_pulled_from_rfc_queue(request, doc, comment, prev_state, next_state):
108163
extra=extra_automation_headers(doc)

ietf/doc/tests_draft.py

Lines changed: 200 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1707,11 +1707,12 @@ def test_adopt_document(self):
17071707
self.assertEqual(draft.group, chair_role.group)
17081708
self.assertEqual(draft.stream_id, stream_state_type_slug[type_id][13:]) # trim off "draft-stream-"
17091709
self.assertEqual(draft.docevent_set.count() - events_before, 5)
1710-
self.assertEqual(len(outbox), 1)
1711-
self.assertTrue("Call For Adoption" in outbox[-1]["Subject"])
1712-
self.assertTrue(f"{chair_role.group.acronym}-chairs@" in outbox[-1]['To'])
1713-
self.assertTrue(f"{draft.name}@" in outbox[-1]['To'])
1714-
self.assertTrue(f"{chair_role.group.acronym}@" in outbox[-1]['To'])
1710+
self.assertEqual(len(outbox), 2)
1711+
self.assertTrue("Call For Adoption" in outbox[0]["Subject"])
1712+
self.assertTrue(f"{chair_role.group.acronym}-chairs@" in outbox[0]['To'])
1713+
self.assertTrue(f"{draft.name}@" in outbox[0]['To'])
1714+
self.assertTrue(f"{chair_role.group.acronym}@" in outbox[0]['To'])
1715+
# contents of outbox[1] are tested elsewhere
17151716

17161717
# adopt
17171718
empty_outbox()
@@ -2001,6 +2002,200 @@ def test_set_state(self):
20012002
self.assertTrue("[email protected]" in outbox[0].as_string())
20022003
self.assertTrue("[email protected]" in outbox[0].as_string())
20032004

2005+
def test_wg_call_for_adoption_issued(self):
2006+
role = RoleFactory(
2007+
name_id="chair",
2008+
group__acronym="mars",
2009+
group__list_email="[email protected]",
2010+
person__user__username="marschairman",
2011+
person__name="WG Cháir Man",
2012+
)
2013+
# First test the usual workflow through the manage adoption view
2014+
draft = IndividualDraftFactory()
2015+
url = urlreverse(
2016+
"ietf.doc.views_draft.adopt_draft", kwargs=dict(name=draft.name)
2017+
)
2018+
login_testing_unauthorized(self, "marschairman", url)
2019+
empty_outbox()
2020+
call_issued = State.objects.get(type="draft-stream-ietf", slug="c-adopt")
2021+
r = self.client.post(
2022+
url,
2023+
dict(
2024+
comment="some comment",
2025+
group=role.group.pk,
2026+
newstate=call_issued.pk,
2027+
weeks="10",
2028+
),
2029+
)
2030+
self.assertEqual(r.status_code, 302)
2031+
self.assertEqual(len(outbox), 2)
2032+
self.assertIn("[email protected]", outbox[1]["To"])
2033+
self.assertIn("Call for adoption", outbox[1]["Subject"])
2034+
body = get_payload_text(outbox[1])
2035+
self.assertIn("disclosure obligations", body)
2036+
self.assertIn("starts a 10-week", body)
2037+
# Test not entering a duration on the form
2038+
draft = IndividualDraftFactory()
2039+
url = urlreverse(
2040+
"ietf.doc.views_draft.adopt_draft", kwargs=dict(name=draft.name)
2041+
)
2042+
empty_outbox()
2043+
call_issued = State.objects.get(type="draft-stream-ietf", slug="c-adopt")
2044+
r = self.client.post(
2045+
url,
2046+
dict(
2047+
comment="some comment",
2048+
group=role.group.pk,
2049+
newstate=call_issued.pk,
2050+
),
2051+
)
2052+
self.assertEqual(r.status_code, 302)
2053+
self.assertEqual(len(outbox), 2)
2054+
self.assertIn("[email protected]", outbox[1]["To"])
2055+
self.assertIn("Call for adoption", outbox[1]["Subject"])
2056+
body = get_payload_text(outbox[1])
2057+
self.assertIn("disclosure obligations", body)
2058+
self.assertIn("starts a 2-week", body)
2059+
2060+
# Test the less usual workflow of issuing a call for adoption
2061+
# of a document that's already in the ietf stream
2062+
draft = WgDraftFactory(group=role.group)
2063+
url = urlreverse(
2064+
"ietf.doc.views_draft.change_stream_state",
2065+
kwargs=dict(name=draft.name, state_type="draft-stream-ietf"),
2066+
)
2067+
old_state = draft.get_state("draft-stream-%s" % draft.stream_id)
2068+
new_state = State.objects.get(
2069+
used=True, type="draft-stream-%s" % draft.stream_id, slug="c-adopt"
2070+
)
2071+
self.assertNotEqual(old_state, new_state)
2072+
empty_outbox()
2073+
r = self.client.post(
2074+
url,
2075+
dict(
2076+
new_state=new_state.pk,
2077+
comment="some comment",
2078+
weeks="10",
2079+
tags=[
2080+
t.pk
2081+
for t in draft.tags.filter(
2082+
slug__in=get_tags_for_stream_id(draft.stream_id)
2083+
)
2084+
],
2085+
),
2086+
)
2087+
self.assertEqual(r.status_code, 302)
2088+
self.assertEqual(len(outbox), 2)
2089+
self.assertIn("[email protected]", outbox[1]["To"])
2090+
self.assertIn("Call for adoption", outbox[1]["Subject"])
2091+
body = get_payload_text(outbox[1])
2092+
self.assertIn("disclosure obligations", body)
2093+
self.assertIn("starts a 10-week", body)
2094+
draft = WgDraftFactory(group=role.group)
2095+
url = urlreverse(
2096+
"ietf.doc.views_draft.change_stream_state",
2097+
kwargs=dict(name=draft.name, state_type="draft-stream-ietf"),
2098+
)
2099+
old_state = draft.get_state("draft-stream-%s" % draft.stream_id)
2100+
new_state = State.objects.get(
2101+
used=True, type="draft-stream-%s" % draft.stream_id, slug="c-adopt"
2102+
)
2103+
self.assertNotEqual(old_state, new_state)
2104+
empty_outbox()
2105+
r = self.client.post(
2106+
url,
2107+
dict(
2108+
new_state=new_state.pk,
2109+
comment="some comment",
2110+
tags=[
2111+
t.pk
2112+
for t in draft.tags.filter(
2113+
slug__in=get_tags_for_stream_id(draft.stream_id)
2114+
)
2115+
],
2116+
),
2117+
)
2118+
self.assertEqual(r.status_code, 302)
2119+
self.assertEqual(len(outbox), 2)
2120+
self.assertIn("[email protected]", outbox[1]["To"])
2121+
self.assertIn("Call for adoption", outbox[1]["Subject"])
2122+
body = get_payload_text(outbox[1])
2123+
self.assertIn("disclosure obligations", body)
2124+
self.assertIn("starts a 2-week", body)
2125+
2126+
def test_wg_last_call_issued(self):
2127+
role = RoleFactory(
2128+
name_id="chair",
2129+
group__acronym="mars",
2130+
group__list_email="[email protected]",
2131+
person__user__username="marschairman",
2132+
person__name="WG Cháir Man",
2133+
)
2134+
draft = WgDraftFactory(group=role.group)
2135+
url = urlreverse(
2136+
"ietf.doc.views_draft.change_stream_state",
2137+
kwargs=dict(name=draft.name, state_type="draft-stream-ietf"),
2138+
)
2139+
login_testing_unauthorized(self, "marschairman", url)
2140+
old_state = draft.get_state("draft-stream-%s" % draft.stream_id)
2141+
new_state = State.objects.get(
2142+
used=True, type="draft-stream-%s" % draft.stream_id, slug="wg-lc"
2143+
)
2144+
self.assertNotEqual(old_state, new_state)
2145+
empty_outbox()
2146+
r = self.client.post(
2147+
url,
2148+
dict(
2149+
new_state=new_state.pk,
2150+
comment="some comment",
2151+
weeks="10",
2152+
tags=[
2153+
t.pk
2154+
for t in draft.tags.filter(
2155+
slug__in=get_tags_for_stream_id(draft.stream_id)
2156+
)
2157+
],
2158+
),
2159+
)
2160+
self.assertEqual(r.status_code, 302)
2161+
self.assertEqual(len(outbox), 2)
2162+
self.assertIn("[email protected]", outbox[1]["To"])
2163+
self.assertIn("WG Last Call", outbox[1]["Subject"])
2164+
body = get_payload_text(outbox[1])
2165+
self.assertIn("disclosure obligations", body)
2166+
self.assertIn("starts a 10-week", body)
2167+
draft = WgDraftFactory(group=role.group)
2168+
url = urlreverse(
2169+
"ietf.doc.views_draft.change_stream_state",
2170+
kwargs=dict(name=draft.name, state_type="draft-stream-ietf"),
2171+
)
2172+
old_state = draft.get_state("draft-stream-%s" % draft.stream_id)
2173+
new_state = State.objects.get(
2174+
used=True, type="draft-stream-%s" % draft.stream_id, slug="wg-lc"
2175+
)
2176+
self.assertNotEqual(old_state, new_state)
2177+
empty_outbox()
2178+
r = self.client.post(
2179+
url,
2180+
dict(
2181+
new_state=new_state.pk,
2182+
comment="some comment",
2183+
tags=[
2184+
t.pk
2185+
for t in draft.tags.filter(
2186+
slug__in=get_tags_for_stream_id(draft.stream_id)
2187+
)
2188+
],
2189+
),
2190+
)
2191+
self.assertEqual(r.status_code, 302)
2192+
self.assertEqual(len(outbox), 2)
2193+
self.assertIn("[email protected]", outbox[1]["To"])
2194+
self.assertIn("WG Last Call", outbox[1]["Subject"])
2195+
body = get_payload_text(outbox[1])
2196+
self.assertIn("disclosure obligations", body)
2197+
self.assertIn("starts a 2-week", body)
2198+
20042199
def test_pubreq_validation(self):
20052200
role = RoleFactory(name_id='chair',group__acronym='mars',group__list_email='[email protected]',person__user__username='marschairman',person__name='WG Cháir Man')
20062201
RoleFactory(name_id='delegate',group=role.group,person__user__email='[email protected]')

ietf/doc/views_draft.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
IanaExpertDocEvent, IESG_SUBSTATE_TAGS)
2929
from ietf.doc.mails import ( email_pulled_from_rfc_queue, email_resurrect_requested,
3030
email_resurrection_completed, email_state_changed, email_stream_changed,
31+
email_wg_call_for_adoption_issued, email_wg_last_call_issued,
3132
email_stream_state_changed, email_stream_tags_changed, extra_automation_headers,
3233
generate_publication_request, email_adopted, email_intended_status_changed,
3334
email_iesg_processing_document, email_ad_approved_doc,
@@ -1568,8 +1569,15 @@ def adopt_draft(request, name):
15681569

15691570
update_reminder(doc, "stream-s", e, due_date)
15701571

1572+
# The following call name is very misleading - the view allows
1573+
# setting states that are _not_ the adopted state.
15711574
email_adopted(request, doc, prev_state, new_state, by, comment)
15721575

1576+
# Currently only the IETF stream uses the c-adopt state - guard against other
1577+
# streams starting to use it asthe IPR rules for those streams will be different.
1578+
if doc.stream_id == "ietf" and new_state.slug == "c-adopt":
1579+
email_wg_call_for_adoption_issued(request, doc, cfa_duration_weeks=form.cleaned_data["weeks"])
1580+
15731581
# comment
15741582
if comment:
15751583
e = DocEvent(type="added_comment", doc=doc, rev=doc.rev, by=by)
@@ -1754,13 +1762,20 @@ def change_stream_state(request, name, state_type):
17541762
events.append(e)
17551763

17561764
due_date = None
1757-
if form.cleaned_data["weeks"] != None:
1765+
if form.cleaned_data["weeks"] is not None:
17581766
due_date = datetime_today(DEADLINE_TZINFO) + datetime.timedelta(weeks=form.cleaned_data["weeks"])
17591767

17601768
update_reminder(doc, "stream-s", e, due_date)
17611769

17621770
email_stream_state_changed(request, doc, prev_state, new_state, by, comment)
17631771

1772+
if doc.stream_id == "ietf":
1773+
if new_state.slug == "c-adopt":
1774+
email_wg_call_for_adoption_issued(request, doc, cfa_duration_weeks=form.cleaned_data["weeks"])
1775+
1776+
if new_state.slug == "wg-lc":
1777+
email_wg_last_call_issued(request, doc, wglc_duration_weeks=form.cleaned_data["weeks"])
1778+
17641779
# tags
17651780
existing_tags = set(doc.tags.all())
17661781
new_tags = set(form.cleaned_data["tags"])
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Copyright The IETF Trust 2023, All Rights Reserved
2+
3+
from django.db import migrations
4+
5+
6+
def forward(apps, schema_editor):
7+
MailTrigger = apps.get_model("mailtrigger", "MailTrigger")
8+
Recipient = apps.get_model("mailtrigger", "Recipient")
9+
recipients = list(
10+
Recipient.objects.filter(
11+
slug__in=(
12+
"doc_group_mail_list",
13+
"doc_authors",
14+
"doc_group_chairs",
15+
"doc_shepherd",
16+
)
17+
)
18+
)
19+
call_for_adoption = MailTrigger.objects.create(
20+
slug="doc_wg_call_for_adoption_issued",
21+
desc="Recipients when a working group call for adoption is issued",
22+
)
23+
call_for_adoption.to.add(*recipients)
24+
wg_last_call = MailTrigger.objects.create(
25+
slug="doc_wg_last_call_issued",
26+
desc="Recipients when a working group last call is issued",
27+
)
28+
wg_last_call.to.add(*recipients)
29+
30+
31+
def reverse(apps, schema_editor):
32+
MailTrigger = apps.get_model("mailtrigger", "MailTrigger")
33+
MailTrigger.objects.filter(
34+
slug_in=("doc_wg_call_for_adoption_issued", "doc_wg_last_call_issued")
35+
).delete()
36+
37+
38+
class Migration(migrations.Migration):
39+
dependencies = [
40+
("mailtrigger", "0005_rfc_recipients"),
41+
]
42+
43+
operations = [migrations.RunPython(forward, reverse)]

0 commit comments

Comments
 (0)